Compare commits

...

8 Commits

Author SHA1 Message Date
shahondin1624 96172a880e feat: global unified search bar with category filtering (v0.4.0)
- New UnifiedSearchController and UnifiedSearchService backend API
  - Single endpoint GET /api/v1/search?q=...&category=...&limit=...
  - Searches across 6 categories: Mitglieder, Familien, Lager, Inventar, Unfälle, Beiträge
  - Highlighted match text with <mark> tags in results
  - Per-category result routing to appropriate detail views

- New SearchBar.vue component
  - Category filter chips (Alle, Mitglieder, Familien, Lager, Inventar, Unfälle, Beiträge)
  - Debounced search with 300ms delay
  - Keyboard navigation (arrow keys, enter, escape)
  - Stale request handling with request ID counter
  - Breadcrumb back navigation
  - Grouped results display with highlighted matches
  - Empty states and loading indicators

- Cleaned up inline search inputs from:
  - FamilyList.vue (removed member search text field)
  - Inventory.vue (removed general/stock search text fields)
  - PermissionSettings.vue (removed user filter text field)
  - FamilyDetail.vue (converted to client-side filtering)

- Removed client-side search actions from stores:
  - members.js (removed searchMembers)
  - families.js (removed searchFamilies)
  - general.js (removed searchText + filteredItems)
  - stock.js (removed searchText + filteredItems)

- Added search() methods to existing mappers:
  - LagerMapper::search() - searches camps by name, description, attending members
  - InjuryMapper::search() - searches injuries by description, activity, member name
  - GeneralMaterialMapper::search() - searches general material by name
  - StockItemMapper::search() - searches stock items by name
  - FeeRecordMapper::search() - searches fee records by member name

- Fixed pre-existing entity property type issue
  - Changed protected  to public  in GeneralMaterial, InventoryCategory,
    SaleRecord, StockItem, StockVariant entities

- Bumped version 0.3.2 → 0.4.0 in info.xml, webpack.config.js, main.js

(Closes #200)
2026-04-22 12:52:14 +02:00
shahondin1624 53b3fd945a Release v0.3.2: Inventory tracking, test infra, and bugfixes
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 43s
Inventory:
- General material, stock items, and sales CRUD
- Category management with CategoryPicker component
- Inventory reports service
- Database migrations and mappers

Frontend:
- Inventory view with tabs (Allgemeinmaterial, Verkaufsmaterial, Verkäufe)
- Forms for general material, stock items, and sales
- Search bar fix: flexbox wrapper with inline icon replaces broken NcTextField icon slot

Tests:
- All 1,491 tests pass (zero errors, warnings, deprecations)
- BundleImportServiceTest: fixed ZipArchive empty-file deprecation
- BundleImportService: null-coalescing for targetFields
- PHPUnit test infra: make test target via container

CLAUDE.md:
- Added Testing section as PR gating criterion
2026-04-22 10:15:22 +02:00
shahondin1624 ff60c4088e Add Testing section to CLAUDE.md
All PHPUnit tests must pass before merging. Zero errors/warnings/deprecations is the baseline.
2026-04-22 10:14:01 +02:00
shahondin1624 9a55ef4677 Fix BundleImportServiceTest: 0 errors, 0 warnings
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 4s
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 51s
- createZipContent: check file_exists before file_get_contents, handle
  ZipArchive close on empty archives
- extractCsvFiles: check filesize before ZipArchive::open to avoid
  'Using empty file is deprecated' warning
- previewBundle: use null coalescing for $schema['targetFields']
2026-04-22 10:09:13 +02:00
shahondin1624 a9f3dc82ae Add Makefile test target for running PHPUnit inside container
Supports:
  make test              # all suites (Unit, Integration, DatabasePortability)
  make test suite=Unit
  make test suite=Integration
  make test suite=DatabasePortability

Also switches composer to install dev dependencies so PHPUnit is available.
2026-04-22 09:59:06 +02:00
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
63 changed files with 6436 additions and 244 deletions
+4
View File
@@ -36,3 +36,7 @@ test-results/
# Release artifacts
artifacts/
# agent
plan.md
review.md
+4
View File
@@ -41,6 +41,10 @@ These caused most bugs in this project. Every contributor must know them:
- Fetch via `@nextcloud/axios` + `generateUrl()`
- Always expose `clearError()` action
## Testing
All PHPUnit tests must pass (`make test`) before merging. Runs inside the Nextcloud container — no local PHP needed. Zero errors, warnings, and PHP deprecations is the baseline for a commitable PR.
## UI Guidelines
- Buttons must show full text, never truncated. Global fix in `main.js` handles NcButton overflow.
+35 -2
View File
@@ -1,11 +1,12 @@
.PHONY: build deps up down setup deploy redeploy logs clean package release \
up-postgres setup-postgres deploy-postgres clean-postgres \
up-sqlite setup-sqlite deploy-sqlite clean-sqlite
up-sqlite setup-sqlite deploy-sqlite clean-sqlite \
test
# Install dependencies (composer via Docker since PHP may not be local)
deps:
npm install --no-audit --no-fund
docker run --rm -v "$$(pwd):/app" -w /app composer:2 install --no-dev --optimize-autoloader --no-interaction
docker run --rm -v "$$(pwd):/app" -w /app composer:2 install --optimize-autoloader --no-interaction
# Build frontend
build: deps
@@ -202,6 +203,38 @@ deploy-sqlite: build up-sqlite setup-sqlite
clean-sqlite:
docker compose -f docker/sqlite/docker-compose.yml down -v
# Run all tests inside the Nextcloud container
# Supports: make test [suite=Unit] [suite=Integration] [suite=DatabasePortability]
test:
@if ! docker compose exec -T nextcloud test -f config/config.php 2>/dev/null; then \
echo "ERROR: Nextcloud container is not running."; \
echo " Start it with: make up && make setup"; \
exit 1; \
fi
@# Ensure app code is up to date inside the container
docker compose exec nextcloud cp -a /app-src/appinfo /var/www/html/custom_apps/mitgliederverwaltung/
docker compose exec nextcloud cp -a /app-src/lib /var/www/html/custom_apps/mitgliederverwaltung/
docker compose exec nextcloud cp -a /app-src/tests /var/www/html/custom_apps/mitgliederverwaltung/
docker compose exec nextcloud cp -a /app-src/phpunit.xml /var/www/html/custom_apps/mitgliederverwaltung/
docker compose exec nextcloud chown -R www-data:www-data /var/www/html/custom_apps/mitgliederverwaltung
@# Copy composer vendor dir with dev deps into app for PHPUnit
docker compose exec nextcloud cp -a /app-src/vendor /var/www/html/custom_apps/mitgliederverwaltung/
@# Determine which test suite to run (default: all)
@if [ "$(suite)" = "" ]; then \
echo "Running all tests..."; \
docker compose exec -u www-data -T nextcloud \
php /var/www/html/custom_apps/mitgliederverwaltung/vendor/bin/phpunit \
-c /var/www/html/custom_apps/mitgliederverwaltung/phpunit.xml \
--colors=always; \
else \
echo "Running suite $(suite)..."; \
docker compose exec -u www-data -T nextcloud \
php /var/www/html/custom_apps/mitgliederverwaltung/vendor/bin/phpunit \
-c /var/www/html/custom_apps/mitgliederverwaltung/phpunit.xml \
--testsuite $(suite) \
--colors=always; \
fi
# Remove volumes (full reset)
clean:
docker compose down -v
+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.4.0</version>
<licence>agpl</licence>
<author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace>
+30
View File
@@ -7,6 +7,9 @@ return [
// ── Page routes ──────────────────────────────────────────────
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
// ── Unified search ───────────────────────────────────────────
['name' => 'unifiedSearch#search', 'url' => '/api/v1/search', 'verb' => 'GET'],
// ── Member search ───────────────────────────────────────────
['name' => 'member#search', 'url' => '/api/v1/members/search', 'verb' => 'GET'],
@@ -188,5 +191,32 @@ return [
['name' => 'file#ensureFolder', 'url' => '/api/v1/members/{memberId}/files/ensure-folder', 'verb' => 'POST'],
['name' => 'file#lagerFiles', 'url' => '/api/v1/lager/{lagerId}/files/browse', 'verb' => 'GET'],
['name' => 'file#ensureLagerFolder', 'url' => '/api/v1/lager/{lagerId}/files/ensure-folder', 'verb' => 'POST'],
// ── Inventory Tracking ──────────────────────────────────────
['name' => 'inventory#listCategories', 'url' => '/api/v1/inventory/categories', 'verb' => 'GET'],
['name' => 'inventory#createCategory', 'url' => '/api/v1/inventory/categories', 'verb' => 'POST'],
['name' => 'inventory#updateCategory', 'url' => '/api/v1/inventory/categories/{id}', 'verb' => 'PUT'],
['name' => 'inventory#deleteCategory', 'url' => '/api/v1/inventory/categories/{id}', 'verb' => 'DELETE'],
['name' => 'inventory#listGeneral', 'url' => '/api/v1/inventory/general', 'verb' => 'GET'],
['name' => 'inventory#showGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'GET'],
['name' => 'inventory#createGeneral', 'url' => '/api/v1/inventory/general', 'verb' => 'POST'],
['name' => 'inventory#updateGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'PUT'],
['name' => 'inventory#deleteGeneral', 'url' => '/api/v1/inventory/general/{id}', 'verb' => 'DELETE'],
['name' => 'inventory#listStock', 'url' => '/api/v1/inventory/stock', 'verb' => 'GET'],
['name' => 'inventory#showStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'GET'],
['name' => 'inventory#createStock', 'url' => '/api/v1/inventory/stock', 'verb' => 'POST'],
['name' => 'inventory#updateStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'PUT'],
['name' => 'inventory#deleteStock', 'url' => '/api/v1/inventory/stock/{id}', 'verb' => 'DELETE'],
['name' => 'inventory#createVariant', 'url' => '/api/v1/inventory/variants', 'verb' => 'POST'],
['name' => 'inventory#updateVariant', 'url' => '/api/v1/inventory/variants/{id}', 'verb' => 'PUT'],
['name' => 'inventory#deleteVariant', 'url' => '/api/v1/inventory/variants/{id}', 'verb' => 'DELETE'],
['name' => 'inventory#listSales', 'url' => '/api/v1/inventory/sales', 'verb' => 'GET'],
['name' => 'inventory#showSale', 'url' => '/api/v1/inventory/sales/{id}', 'verb' => 'GET'],
['name' => 'inventory#createSale', 'url' => '/api/v1/inventory/sales', 'verb' => 'POST'],
['name' => 'inventory#deleteSale', 'url' => '/api/v1/inventory/sales/{id}', 'verb' => 'DELETE'],
// ── Inventory Reports ─────────────────────────────────────
['name' => 'inventoryReport#condition', 'url' => '/api/v1/inventory/reports/condition', 'verb' => 'GET'],
['name' => 'inventoryReport#sales', 'url' => '/api/v1/inventory/reports/sales', 'verb' => 'GET'],
],
];
+2 -1
View File
@@ -26,7 +26,8 @@ services:
env_file: .env
environment:
MYSQL_HOST: db
NEXTCLOUD_TRUSTED_DOMAINS: "localhost"
NEXTCLOUD_TRUSTED_DOMAINS: "localhost,192.168.2.35"
OVERWRITEHOST: "192.168.2.35"
volumes:
- nc_data:/var/www/html
- ./:/app-src:ro
+510
View File
@@ -0,0 +1,510 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\InventoryService;
use OCA\Mitgliederverwaltung\Service\SaleService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* REST API controller for inventory CRUD operations.
*
* Part of Issue #165 (Inventory Tracking).
*/
class InventoryController extends ApiController {
use ApiControllerTrait;
private InventoryService $inventoryService;
private SaleService $saleService;
private IUserSession $userSession;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
InventoryService $inventoryService,
SaleService $saleService,
IUserSession $userSession,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->inventoryService = $inventoryService;
$this->saleService = $saleService;
$this->userSession = $userSession;
$this->logger = $logger;
}
// ── Categories ──────────────────────────────────────────────────
/**
* List all categories.
*
* GET /api/v1/inventory/categories
*/
#[NoCSRFRequired]
public function listCategories(): JSONResponse {
try {
$categories = $this->inventoryService->getCategories();
return new JSONResponse(['data' => array_map(fn($c) => $c->jsonSerialize(), $categories)]);
} catch (\Exception $e) {
$this->logger->error('Failed to list inventory categories', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a category.
*
* POST /api/v1/inventory/categories
*/
public function createCategory(): JSONResponse {
try {
$data = $this->getRequestData();
if (empty($data['name'])) {
return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST);
}
$category = $this->inventoryService->createCategory($data);
return new JSONResponse($category->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a category.
*
* PUT /api/v1/inventory/categories/{id}
*/
public function updateCategory(int $id): JSONResponse {
try {
$data = $this->getRequestData();
$category = $this->inventoryService->updateCategory($id, $data);
return new JSONResponse($category->jsonSerialize());
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Kategorie nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to update inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a category.
*
* DELETE /api/v1/inventory/categories/{id}
*/
public function deleteCategory(int $id): JSONResponse {
try {
$this->inventoryService->deleteCategory($id);
return new JSONResponse(['status' => 'deleted']);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Kategorie nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to delete inventory category', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
// ── General Material ────────────────────────────────────────────
/**
* List all general material items.
*
* GET /api/v1/inventory/general
*/
#[NoCSRFRequired]
public function listGeneral(): JSONResponse {
try {
$items = $this->inventoryService->getGeneralMaterial();
$result = [];
foreach ($items as $item) {
$data = $item->jsonSerialize();
$data['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId());
$result[] = $data;
}
return new JSONResponse(['data' => $result]);
} catch (\Exception $e) {
$this->logger->error('Failed to list general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get a single general material item.
*
* GET /api/v1/inventory/general/{id}
*/
#[NoCSRFRequired]
public function showGeneral(int $id): JSONResponse {
try {
$item = $this->inventoryService->getGeneralMaterialById($id);
$data = $item->jsonSerialize();
$data['categories'] = $this->inventoryService->getCategoryNamesForItem($id);
return new JSONResponse($data);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to get general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a general material item.
*
* POST /api/v1/inventory/general
*/
public function createGeneral(): JSONResponse {
try {
$data = $this->getRequestData();
if (empty($data['name'])) {
return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST);
}
$categoryIds = $data['categories'] ?? null;
unset($data['categories']);
$item = $this->inventoryService->createGeneralMaterial($data, $categoryIds);
$result = $item->jsonSerialize();
$result['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId());
return new JSONResponse($result, Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a general material item.
*
* PUT /api/v1/inventory/general/{id}
*/
public function updateGeneral(int $id): JSONResponse {
try {
$data = $this->getRequestData();
$categoryIds = $data['categories'] ?? null;
unset($data['categories']);
$item = $this->inventoryService->updateGeneralMaterial($id, $data, $categoryIds);
$result = $item->jsonSerialize();
$result['categories'] = $this->inventoryService->getCategoryNamesForItem($id);
return new JSONResponse($result);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to update general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a general material item.
*
* DELETE /api/v1/inventory/general/{id}
*/
public function deleteGeneral(int $id): JSONResponse {
try {
$this->inventoryService->deleteGeneralMaterial($id);
return new JSONResponse(['status' => 'deleted']);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Material nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to delete general material', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
// ── Stock Items ─────────────────────────────────────────────────
/**
* List all stock items with their variants.
*
* GET /api/v1/inventory/stock
*/
#[NoCSRFRequired]
public function listStock(): JSONResponse {
try {
$items = $this->inventoryService->getStockItems();
$result = [];
foreach ($items as $item) {
$data = $item->jsonSerialize();
$data['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId());
$data['variants'] = array_map(
fn($v) => $v->jsonSerialize(),
$this->inventoryService->getVariantsByStockItem($item->getId())
);
// Calculate total amount from variants
$totalAmount = 0;
foreach ($data['variants'] as $v) {
$totalAmount += (int)($v['amount'] ?? 0);
}
$data['total_amount'] = $totalAmount;
// Calculate total minimum threshold
$minThreshold = 0;
foreach ($data['variants'] as $v) {
$minThreshold += (int)($v['min_threshold'] ?? 0);
}
$data['min_threshold'] = $minThreshold;
$result[] = $data;
}
return new JSONResponse(['data' => $result]);
} catch (\Exception $e) {
$this->logger->error('Failed to list stock items', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get a single stock item with its variants.
*
* GET /api/v1/inventory/stock/{id}
*/
#[NoCSRFRequired]
public function showStock(int $id): JSONResponse {
try {
$item = $this->inventoryService->getStockItemById($id);
$data = $item->jsonSerialize();
$data['categories'] = $this->inventoryService->getCategoryNamesForItem($id);
$data['variants'] = array_map(
fn($v) => $v->jsonSerialize(),
$this->inventoryService->getVariantsByStockItem($id)
);
return new JSONResponse($data);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to get stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a stock item.
*
* POST /api/v1/inventory/stock
*/
public function createStock(): JSONResponse {
try {
$data = $this->getRequestData();
if (empty($data['name'])) {
return new JSONResponse(['error' => 'Name ist erforderlich'], Http::STATUS_BAD_REQUEST);
}
$categoryIds = $data['categories'] ?? null;
$variants = $data['variants'] ?? [];
unset($data['categories'], $data['variants']);
$item = $this->inventoryService->createStockItem($data, $categoryIds);
// Create variants if provided
foreach ($variants as $vData) {
$vData['stock_item_id'] = $item->getId();
$this->inventoryService->createStockVariant($vData);
}
$result = $item->jsonSerialize();
$result['categories'] = $this->inventoryService->getCategoryNamesForItem($item->getId());
return new JSONResponse($result, Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a stock item.
*
* PUT /api/v1/inventory/stock/{id}
*/
public function updateStock(int $id): JSONResponse {
try {
$data = $this->getRequestData();
$categoryIds = $data['categories'] ?? null;
$variants = $data['variants'] ?? [];
unset($data['categories'], $data['variants']);
$item = $this->inventoryService->updateStockItem($id, $data, $categoryIds);
// Update/create/delete variants
foreach ($variants as $vData) {
if (isset($vData['id']) && is_numeric($vData['id'])) {
// Update existing variant
$this->inventoryService->updateStockVariant((int)$vData['id'], $vData);
} else {
// Create new variant
$vData['stock_item_id'] = $id;
$this->inventoryService->createStockVariant($vData);
}
}
// Delete variants that are no longer present
$existing = $this->inventoryService->getVariantsByStockItem($id);
$presentIds = array_map(fn($v) => (int)($v['id'] ?? 0), $variants);
foreach ($existing as $existingVariant) {
if (!in_array((int)$existingVariant->getId(), $presentIds, true)) {
$this->inventoryService->deleteStockVariant((int)$existingVariant->getId());
}
}
$result = $item->jsonSerialize();
$result['categories'] = $this->inventoryService->getCategoryNamesForItem($id);
return new JSONResponse($result);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to update stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a stock item.
*
* DELETE /api/v1/inventory/stock/{id}
*/
public function deleteStock(int $id): JSONResponse {
try {
$this->inventoryService->deleteStockItem($id);
return new JSONResponse(['status' => 'deleted']);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Verkaufsmaterial nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to delete stock item', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
// ── Variants ────────────────────────────────────────────────────
/**
* Create a variant (standalone endpoint).
*
* POST /api/v1/inventory/variants
*/
public function createVariant(): JSONResponse {
try {
$data = $this->getRequestData();
$variant = $this->inventoryService->createStockVariant($data);
return new JSONResponse($variant->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Update a variant.
*
* PUT /api/v1/inventory/variants/{id}
*/
public function updateVariant(int $id): JSONResponse {
try {
$data = $this->getRequestData();
$variant = $this->inventoryService->updateStockVariant($id, $data);
return new JSONResponse($variant->jsonSerialize());
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Variante nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to update stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a variant.
*
* DELETE /api/v1/inventory/variants/{id}
*/
public function deleteVariant(int $id): JSONResponse {
try {
$this->inventoryService->deleteStockVariant($id);
return new JSONResponse(['status' => 'deleted']);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Variante nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to delete stock variant', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
// ── Sales ───────────────────────────────────────────────────────
/**
* List all sales.
*
* GET /api/v1/inventory/sales
*/
#[NoCSRFRequired]
public function listSales(): JSONResponse {
try {
$from = $this->request->getParam('dateFrom');
$to = $this->request->getParam('dateTo');
$sales = $this->saleService->getSalesByDateRange($from, $to);
return new JSONResponse(['data' => array_map(fn($s) => $s->jsonSerialize(), $sales)]);
} catch (\Exception $e) {
$this->logger->error('Failed to list sales', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get a single sale.
*
* GET /api/v1/inventory/sales/{id}
*/
#[NoCSRFRequired]
public function showSale(int $id): JSONResponse {
try {
$sale = $this->saleService->getSaleById($id);
return new JSONResponse($sale->jsonSerialize());
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Verkauf nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to get sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Create a sale record.
*
* POST /api/v1/inventory/sales
*/
public function createSale(): JSONResponse {
try {
$data = $this->getRequestData();
$sale = $this->saleService->createSale($data);
return new JSONResponse($sale->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Failed to create sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete a sale record.
*
* DELETE /api/v1/inventory/sales/{id}
*/
public function deleteSale(int $id): JSONResponse {
try {
$this->saleService->deleteSale($id);
return new JSONResponse(['status' => 'deleted']);
} catch (DoesNotExistException $e) {
return new JSONResponse(['error' => 'Verkauf nicht gefunden'], Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
$this->logger->error('Failed to delete sale', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* REST API controller for inventory reports.
*
* Part of Issue #165 (Inventory Tracking).
*/
class InventoryReportController extends ApiController {
private InventoryReportService $reportService;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
InventoryReportService $reportService,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->reportService = $reportService;
$this->logger = $logger;
}
/**
* Get condition report data.
*
* GET /api/v1/inventory/reports/condition
*/
#[NoCSRFRequired]
public function condition(): JSONResponse {
try {
$report = $this->reportService->generateConditionReport();
$report['title'] = 'Materialzustand';
return new JSONResponse($report);
} catch (\Exception $e) {
$this->logger->error('Failed to generate condition report', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get sales report data.
*
* GET /api/v1/inventory/reports/sales?dateFrom=2026-01-01&dateTo=2026-03-31
*/
#[NoCSRFRequired]
public function sales(): JSONResponse {
try {
$dateFrom = $this->request->getParam('dateFrom');
$dateTo = $this->request->getParam('dateTo');
$report = $this->reportService->generateSalesReport($dateFrom, $dateTo);
return new JSONResponse($report);
} catch (\Exception $e) {
$this->logger->error('Failed to generate sales report', ['exception' => $e, 'app' => 'mitgliederverwaltung']);
return new JSONResponse(['error' => 'Internal server error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}
+11
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\EncryptedExportService;
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
use OCA\Mitgliederverwaltung\Service\PdfService;
use OCA\Mitgliederverwaltung\Service\PermissionService;
use OCA\Mitgliederverwaltung\Service\ReportService;
@@ -31,6 +32,7 @@ use Psr\Log\LoggerInterface;
class ReportController extends ApiController {
private ReportService $reportService;
private InventoryReportService $inventoryReportService;
private PdfService $pdfService;
private EncryptedExportService $encryptedService;
private PermissionService $permissionService;
@@ -41,6 +43,7 @@ class ReportController extends ApiController {
string $appName,
IRequest $request,
ReportService $reportService,
InventoryReportService $inventoryReportService,
PdfService $pdfService,
EncryptedExportService $encryptedService,
PermissionService $permissionService,
@@ -49,6 +52,7 @@ class ReportController extends ApiController {
) {
parent::__construct($appName, $request);
$this->reportService = $reportService;
$this->inventoryReportService = $inventoryReportService;
$this->pdfService = $pdfService;
$this->encryptedService = $encryptedService;
$this->permissionService = $permissionService;
@@ -75,6 +79,8 @@ class ReportController extends ApiController {
['id' => 'familienliste', 'name' => 'Familienliste', 'params' => []],
['id' => 'lagerhistorie', 'name' => 'Lagerhistorie', 'params' => ['memberId']],
['id' => 'verletzungsprotokoll', 'name' => 'Verletzungsprotokoll', 'params' => ['dateFrom', 'dateTo', 'memberId']],
['id' => 'inventur-verkaeufe', 'name' => 'Inventur-Verkaeufe', 'params' => ['dateFrom', 'dateTo']],
['id' => 'materialzustand', 'name' => 'Materialzustand', 'params' => []],
],
]);
}
@@ -251,6 +257,11 @@ class ReportController extends ApiController {
? (int)$this->request->getParam('memberId')
: null
),
'inventur-verkaeufe' => $this->inventoryReportService->generateSalesReport(
$this->request->getParam('dateFrom') ?: null,
$this->request->getParam('dateTo') ?: null
),
'materialzustand' => $this->inventoryReportService->generateConditionReport(),
default => null,
};
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\UnifiedSearchService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* Unified search API controller.
*
* Single endpoint that searches across all entity categories.
*
* Part of Issue #200.
*/
class UnifiedSearchController extends ApiController {
use ApiControllerTrait;
private UnifiedSearchService $searchService;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
UnifiedSearchService $searchService,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->searchService = $searchService;
$this->logger = $logger;
}
/**
* Unified search across all categories.
*
* GET /api/v1/search?q=<query>&category=<cat>&limit=<n>
*
* Allowed categories:
* - alle (default): search all categories
* - mitglieder: search members
* - familien: search families
* - lager: search camps
* - inventar: search inventory (general + stock)
* - unfalle: search injury records
* - beitraege: search fee records
*
* @NoCSRFRequired
* @NoAdminRequired
*/
#[NoCSRFRequired]
public function search(): JSONResponse {
try {
$query = $this->request->getParam('q', '');
$category = $this->request->getParam('category', 'alle');
$limit = (int)($this->request->getParam('limit', '10'));
// Clamp limit
$limit = max(1, min($limit, 50));
if (trim($query) === '' || strlen($query) < 2) {
return new JSONResponse([
'data' => [
'mitglieder' => [],
'familien' => [],
'lager' => [],
'inventar' => [],
'unfalle' => [],
'beitrage' => [],
],
]);
}
$results = $this->searchService->search($query, $category, $limit);
return new JSONResponse(['data' => $results]);
} catch (\Exception $e) {
$this->logger->error('Unified search failed', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Interner Serverfehler'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
}
+29
View File
@@ -102,6 +102,35 @@ class FeeRecordMapper extends QBMapper {
return $this->findEntity($qb);
}
/**
* Search fee records by associated member name.
*
* Part of Issue #200 (Unified Search).
*
* @param string $query Search query
* @param int $limit Max results
* @return FeeRecord[]
* @throws Exception
*/
public function search(string $query, int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('f.*', 'm.vorname AS member_vorname', 'm.nachname AS member_nachname')
->from($this->getTableName(), 'f')
->leftJoin('f', 'mv_members', 'm', $qb->expr()->eq('f.member_id', 'm.id'))
->where(
$qb->expr()->orX(
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('f.year', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Count records for a year.
*
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* General material entity mapping all columns from oc_mv_inventory_general_material.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getId()
* @method void setId(int $id)
* @method string getName()
* @method void setName(string $name)
* @method int|null getCondition()
* @method void setCondition(?int $condition)
* @method string|null getNotes()
* @method void setNotes(?string $notes)
* @method string getCreatedAt()
* @method void setCreatedAt(string $createdAt)
* @method string getUpdatedAt()
* @method void setUpdatedAt(string $updatedAt)
*/
class GeneralMaterial extends Entity implements JsonSerializable {
public $id = 0;
protected string $name = '';
protected ?int $condition = null;
protected ?string $notes = null;
protected string $createdAt = '';
protected string $updatedAt = '';
public function __construct() {
$this->addType('id', 'integer');
$this->addType('condition', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'name' => $this->name,
'condition' => $this->condition,
'notes' => $this->notes,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_general_material table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<GeneralMaterial>
*/
class GeneralMaterialMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_general_material', GeneralMaterial::class);
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function findById(int $id): GeneralMaterial {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Find all general material items.
*
* @return GeneralMaterial[]
* @throws Exception
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->orderBy('name', 'ASC');
return $this->findEntities($qb);
}
/**
* Find items whose condition falls within the given range.
*
* @param int|null $min Minimum condition (inclusive), null = unbounded
* @param int|null $max Maximum condition (inclusive), null = unbounded
* @return GeneralMaterial[]
* @throws Exception
*/
public function findByConditionRange(?int $min = null, ?int $max = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNotNull('condition'));
if ($min !== null) {
$qb->andWhere($qb->expr()->gte('condition', $qb->createNamedParameter($min, IQueryBuilder::PARAM_INT)));
}
if ($max !== null) {
$qb->andWhere($qb->expr()->lte('condition', $qb->createNamedParameter($max, IQueryBuilder::PARAM_INT)));
}
return $this->findEntities($qb);
}
/**
* Search general material items by name.
*
* Part of Issue #200 (Unified Search).
*
* @param string $query Search query
* @param int $limit Max results
* @return GeneralMaterial[]
* @throws Exception
*/
public function search(string $query, int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->iLike('name', $qb->createNamedParameter($searchPattern)))
->orderBy('name', 'ASC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Find items needing repair (condition ≤ 2 or NULL).
*
* @return GeneralMaterial[]
* @throws Exception
*/
public function findNeedingRepair(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->lte('condition', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT)))
->orWhere($qb->expr()->isNull('condition'));
return $this->findEntities($qb);
}
}
+31
View File
@@ -91,6 +91,37 @@ class InjuryMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Search injuries by description, activity, or associated member name.
*
* Part of Issue #200 (Unified Search).
*
* @param string $query Search query
* @param int $limit Max results
* @return Injury[]
* @throws Exception
*/
public function search(string $query, int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('i.*', 'm.vorname AS member_vorname', 'm.nachname AS member_nachname')
->from($this->getTableName(), 'i')
->leftJoin('i', 'mv_members', 'm', $qb->expr()->eq('i.member_id', 'm.id'))
->where(
$qb->expr()->orX(
$qb->expr()->iLike('i.beschreibung', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('i.activity_name', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('i.datum', 'DESC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Find injuries within a date range.
*
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Category entity mapping all columns from oc_mv_inventory_categories.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getId()
* @method void setId(int $id)
* @method string getName()
* @method void setName(string $name)
* @method string|null getCreatedAt()
* @method void setCreatedAt(string $createdAt)
*/
class InventoryCategory extends Entity implements JsonSerializable {
public $id = 0;
protected string $name = '';
protected ?string $createdAt = null;
public function __construct() {
$this->addType('id', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->createdAt,
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_categories table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<InventoryCategory>
*/
class InventoryCategoryMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_categories', InventoryCategory::class);
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function findById(int $id): InventoryCategory {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Find a category by name.
*
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function findByName(string $name): InventoryCategory {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)));
return $this->findEntity($qb);
}
/**
* Find all categories.
*
* @return InventoryCategory[]
* @throws Exception
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->orderBy('name', 'ASC');
return $this->findEntities($qb);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Join entity linking a general material item to a category.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getItemId()
* @method void setItemId(int $itemId)
* @method int|null getCategoryId()
* @method void setCategoryId(int $categoryId)
*/
class InventoryItemCategory extends Entity implements JsonSerializable {
protected int $itemId = 0;
protected int $categoryId = 0;
public function __construct() {
$this->addType('itemId', 'integer');
$this->addType('categoryId', 'integer');
}
public function jsonSerialize(): array {
return [
'item_id' => $this->itemId,
'category_id' => $this->categoryId,
];
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_item_categories join table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<InventoryItemCategory>
*/
class InventoryItemCategoryMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_item_categories', InventoryItemCategory::class);
}
/**
* Find all categories linked to a given item.
*
* @return InventoryItemCategory[]
* @throws Exception
*/
public function findByItemId(int $itemId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('item_id', $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT)));
return $this->findEntities($qb);
}
/**
* Attach a category to an item.
*
* @throws Exception
*/
public function attach(int $itemId, int $categoryId): void {
$qb = $this->db->getQueryBuilder();
$qb->insert($this->getTableName())
->values([
'item_id' => $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT),
'category_id' => $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT),
]);
$qb->executeStatement();
}
/**
* Detach a category from an item.
*
* @throws Exception
*/
public function detach(int $itemId, int $categoryId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where($qb->expr()->eq('item_id', $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
}
+32
View File
@@ -84,6 +84,38 @@ class LagerMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Search camps by name, description, or attending member names.
*
* Part of Issue #200 (Unified Search).
*
* @param string $query Search query
* @param int $limit Max results
* @return Lager[]
* @throws Exception
*/
public function search(string $query, int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->selectDistinct('l.*')
->from($this->getTableName(), 'l')
->leftJoin('l', 'mv_lager_teilnehmer', 'lt', $qb->expr()->eq('l.id', 'lt.lager_id'))
->leftJoin('lt', 'mv_members', 'm', $qb->expr()->eq('lt.member_id', 'm.id'))
->where(
$qb->expr()->orX(
$qb->expr()->iLike('l.name', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('l.beschreibung', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('l.name', 'ASC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Find camps that include a specific Stufe.
*
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Sale record entity mapping all columns from oc_mv_inventory_sales.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getId()
* @method void setId(int $id)
* @method int|null getStockItemId()
* @method void setStockItemId(?int $stockItemId)
* @method int|null getVariantId()
* @method void setVariantId(?int $variantId)
* @method string getDate()
* @method void setDate(string $date)
* @method int getQuantity()
* @method void setQuantity(int $quantity)
* @method string getUnitPrice()
* @method void setUnitPrice(string $unitPrice)
* @method string getTotalPrice()
* @method void setTotalPrice(string $totalPrice)
* @method string|null getNotes()
* @method void setNotes(?string $notes)
* @method string getCreatedAt()
* @method void setCreatedAt(string $createdAt)
*/
class SaleRecord extends Entity implements JsonSerializable {
public $id = 0;
protected ?int $stockItemId = null;
protected ?int $variantId = null;
protected string $date = '';
protected int $quantity = 0;
protected string $unitPrice = '0.00';
protected string $totalPrice = '0.00';
protected ?string $notes = null;
protected string $createdAt = '';
public function __construct() {
$this->addType('id', 'integer');
$this->addType('stockItemId', 'integer');
$this->addType('variantId', 'integer');
$this->addType('quantity', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'stock_item_id' => $this->stockItemId,
'variant_id' => $this->variantId,
'date' => $this->date,
'quantity' => $this->quantity,
'unit_price' => $this->unitPrice,
'total_price' => $this->totalPrice,
'notes' => $this->notes,
'created_at' => $this->createdAt,
];
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_sales table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<SaleRecord>
*/
class SaleRecordMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_sales', SaleRecord::class);
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function findById(int $id): SaleRecord {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Find all sale records.
*
* @return SaleRecord[]
* @throws Exception
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->orderBy('date', 'DESC');
return $this->findEntities($qb);
}
/**
* Find sale records within a date range.
*
* @param string|null $from Start date (inclusive, YYYY-MM-DD)
* @param string|null $to End date (inclusive, YYYY-MM-DD)
* @return SaleRecord[]
* @throws Exception
*/
public function findByDateRange(?string $from = null, ?string $to = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR)))
->addOrderBy('date', 'DESC');
if ($to !== null) {
// Override the first where (set up by gte)
$conditions = $qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR));
$conditions2 = $qb->expr()->lte('date', $qb->createNamedParameter($to, IQueryBuilder::PARAM_STR));
$qb2 = $this->db->getQueryBuilder();
$qb2->select('*')
->from($this->getTableName())
->where($conditions2)
->andWhere($conditions);
return $this->findEntities($qb2);
}
return $this->findEntities($qb);
}
/**
* Count sale records within a date range.
*
* @throws Exception
*/
public function countByDateRange(?string $from = null, ?string $to = null): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->createFunction('COUNT(*)'))
->from($this->getTableName())
->where($qb->expr()->gte('date', $qb->createNamedParameter($from ?? '1970-01-01', IQueryBuilder::PARAM_STR)));
if ($to !== null) {
$qb->andWhere($qb->expr()->lte('date', $qb->createNamedParameter($to, IQueryBuilder::PARAM_STR)));
}
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Stock item entity mapping all columns from oc_mv_inventory_stock_items.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getId()
* @method void setId(int $id)
* @method string getName()
* @method void setName(string $name)
* @method string|null getProviderUrlsJson()
* @method void setProviderUrlsJson(?string $providerUrlsJson)
* @method string getCreatedAt()
* @method void setCreatedAt(string $createdAt)
* @method string getUpdatedAt()
* @method void setUpdatedAt(string $updatedAt)
*/
class StockItem extends Entity implements JsonSerializable {
public $id = 0;
protected string $name = '';
protected ?string $providerUrlsJson = null;
protected string $createdAt = '';
protected string $updatedAt = '';
public function __construct() {
$this->addType('id', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'name' => $this->name,
'provider_urls_json' => $this->providerUrlsJson,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_stock_items table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<StockItem>
*/
class StockItemMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_stock_items', StockItem::class);
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function findById(int $id): StockItem {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Search stock items by name.
*
* Part of Issue #200 (Unified Search).
*
* @param string $query Search query
* @param int $limit Max results
* @return StockItem[]
* @throws Exception
*/
public function search(string $query, int $limit = 10): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->iLike('name', $qb->createNamedParameter($searchPattern)))
->orderBy('name', 'ASC')
->setMaxResults($limit);
return $this->findEntities($qb);
}
/**
* Find all stock items.
*
* @return StockItem[]
* @throws Exception
*/
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->orderBy('name', 'ASC');
return $this->findEntities($qb);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* Stock variant entity mapping all columns from oc_mv_inventory_stock_variants.
*
* Part of Issue #165 (Inventory Tracking).
*
* @method int|null getId()
* @method void setId(int $id)
* @method int getStockItemId()
* @method void setStockItemId(int $stockItemId)
* @method string getLabel()
* @method void setLabel(string $label)
* @method int getAmount()
* @method void setAmount(int $amount)
* @method string getCost()
* @method void setCost(string $cost)
* @method int getMinThreshold()
* @method void setMinThreshold(int $minThreshold)
*/
class StockVariant extends Entity implements JsonSerializable {
public $id = 0;
protected int $stockItemId = 0;
protected string $label = '';
protected int $amount = 0;
protected string $cost = '0.00';
protected int $minThreshold = 0;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('stockItemId', 'integer');
$this->addType('amount', 'integer');
$this->addType('minThreshold', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->id,
'stock_item_id' => $this->stockItemId,
'label' => $this->label,
'amount' => $this->amount,
'cost' => $this->cost,
'min_threshold' => $this->minThreshold,
];
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_inventory_stock_variants table.
*
* Part of Issue #165 (Inventory Tracking).
*
* @extends QBMapper<StockVariant>
*/
class StockVariantMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_inventory_stock_variants', StockVariant::class);
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function findById(int $id): StockVariant {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Find all variants for a given stock item.
*
* @return StockVariant[]
* @throws Exception
*/
public function findByStockItemId(int $stockItemId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('stock_item_id', $qb->createNamedParameter($stockItemId, IQueryBuilder::PARAM_INT)))
->orderBy('label', 'ASC');
return $this->findEntities($qb);
}
}
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Migration: Create inventory tracking tables.
*
* Creates the following tables:
* - mv_inventory_categories — Category registry
* - mv_inventory_item_categories — Join table (item ↔ category)
* - mv_inventory_general_material — General material items
* - mv_inventory_stock_items — Stock / sales items
* - mv_inventory_stock_variants — Variant options per stock item
* - mv_inventory_sales — Sale records (audit trail)
*/
class Version000018Date20260421000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
// ── mv_inventory_categories (no FKs, create first) ───────────
if (!$schema->hasTable('mv_inventory_categories')) {
$table = $schema->createTable('mv_inventory_categories');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 8,
]);
$table->addColumn('name', 'string', [
'notnull' => true,
'length' => 100,
'default' => '',
]);
$table->addColumn('created_at', 'datetime', [
'notnull' => false,
'default' => null,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['name'], 'inventory_cat_name_uindex');
} else {
$table = $schema->getTable('mv_inventory_categories');
if (!$table->hasColumn('created_at')) {
$table->addColumn('created_at', 'datetime', [
'notnull' => false,
'default' => null,
]);
}
if (!$table->hasIndex('inventory_cat_name_uindex')) {
$table->addUniqueIndex(['name'], 'inventory_cat_name_uindex');
}
}
// ── mv_inventory_general_material (referenced by item_categories) ──
if (!$schema->hasTable('mv_inventory_general_material')) {
$table = $schema->createTable('mv_inventory_general_material');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 8,
]);
$table->addColumn('name', 'string', [
'notnull' => true,
'length' => 255,
'default' => '',
]);
$table->addColumn('condition', Types::INTEGER, [
'notnull' => false,
'default' => null,
'unsigned' => true,
]);
$table->addColumn('notes', 'text', [
'notnull' => false,
'default' => null,
'length' => 65535,
]);
$table->addColumn('created_at', 'datetime', [
'notnull' => true,
'default' => '1970-01-01 00:00:00',
]);
$table->addColumn('updated_at', 'datetime', [
'notnull' => true,
'default' => '1970-01-01 00:00:00',
]);
$table->setPrimaryKey(['id']);
}
// ── mv_inventory_stock_items (referenced by stock_variants & sales) ──
if (!$schema->hasTable('mv_inventory_stock_items')) {
$table = $schema->createTable('mv_inventory_stock_items');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 8,
]);
$table->addColumn('name', 'string', [
'notnull' => true,
'length' => 255,
'default' => '',
]);
$table->addColumn('provider_urls_json', 'text', [
'notnull' => false,
'default' => null,
'length' => 65535,
]);
$table->addColumn('created_at', 'datetime', [
'notnull' => true,
'default' => '1970-01-01 00:00:00',
]);
$table->addColumn('updated_at', 'datetime', [
'notnull' => true,
'default' => '1970-01-01 00:00:00',
]);
$table->setPrimaryKey(['id']);
}
// ── mv_inventory_stock_variants (FK → stock_items) ───────────
if (!$schema->hasTable('mv_inventory_stock_variants')) {
$table = $schema->createTable('mv_inventory_stock_variants');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 8,
]);
$table->addColumn('stock_item_id', 'bigint', [
'notnull' => true,
'length' => 8,
'default' => 0,
]);
$table->addColumn('label', 'string', [
'notnull' => true,
'length' => 100,
'default' => '',
]);
$table->addColumn('amount', Types::INTEGER, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('cost', 'decimal', [
'notnull' => true,
'default' => '0.00',
'precision' => 10,
'scale' => 2,
]);
$table->addColumn('min_threshold', Types::INTEGER, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addForeignKeyConstraint(
$schema->getTable('mv_inventory_stock_items'),
['stock_item_id'],
['id'],
['onDelete' => 'CASCADE']
);
}
// ── mv_inventory_item_categories (FK → general_material, categories) ──
if (!$schema->hasTable('mv_inventory_item_categories')) {
$table = $schema->createTable('mv_inventory_item_categories');
$table->addColumn('item_id', 'bigint', [
'notnull' => true,
'length' => 8,
'default' => 0,
]);
$table->addColumn('category_id', 'bigint', [
'notnull' => true,
'length' => 8,
'default' => 0,
]);
$table->setPrimaryKey(['item_id', 'category_id']);
$table->addForeignKeyConstraint(
$schema->getTable('mv_inventory_general_material'),
['item_id'],
['id'],
['onDelete' => 'CASCADE']
);
$table->addForeignKeyConstraint(
$schema->getTable('mv_inventory_categories'),
['category_id'],
['id'],
['onDelete' => 'CASCADE']
);
}
// ── mv_inventory_sales (FK → stock_items, stock_variants) ────
if (!$schema->hasTable('mv_inventory_sales')) {
$table = $schema->createTable('mv_inventory_sales');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'length' => 8,
]);
$table->addColumn('stock_item_id', 'bigint', [
'notnull' => false,
'length' => 8,
'default' => null,
]);
$table->addColumn('variant_id', 'bigint', [
'notnull' => false,
'length' => 8,
'default' => null,
]);
$table->addColumn('date', 'date', [
'notnull' => true,
'default' => '1970-01-01',
]);
$table->addColumn('quantity', Types::INTEGER, [
'notnull' => true,
'default' => 0,
'unsigned' => true,
]);
$table->addColumn('unit_price', 'decimal', [
'notnull' => true,
'default' => '0.00',
'precision' => 10,
'scale' => 2,
]);
$table->addColumn('total_price', 'decimal', [
'notnull' => true,
'default' => '0.00',
'precision' => 10,
'scale' => 2,
]);
$table->addColumn('notes', 'text', [
'notnull' => false,
'default' => null,
'length' => 65535,
]);
$table->addColumn('created_at', 'datetime', [
'notnull' => true,
'default' => '1970-01-01 00:00:00',
]);
$table->setPrimaryKey(['id']);
if ($schema->hasTable('mv_inventory_stock_items')) {
$stockTable = $schema->getTable('mv_inventory_stock_items');
$table->addForeignKeyConstraint($stockTable, ['stock_item_id'], ['id'], ['onDelete' => 'SET NULL']);
}
if ($schema->hasTable('mv_inventory_stock_variants')) {
$varTable = $schema->getTable('mv_inventory_stock_variants');
$table->addForeignKeyConstraint($varTable, ['variant_id'], ['id'], ['onDelete' => 'SET NULL']);
}
}
return $schema;
}
}
+6 -1
View File
@@ -290,7 +290,7 @@ class BundleImportService {
'fkWarnings' => $preview['fkWarnings'],
'errorCount' => count($preview['errors']),
'errors' => array_slice($preview['errors'], 0, 10),
'targetFields' => $schema['targetFields'],
'targetFields' => $schema['targetFields'] ?? [],
];
} catch (\Exception $e) {
$results[] = [
@@ -332,6 +332,11 @@ class BundleImportService {
try {
file_put_contents($tmpFile, $zipContent);
$size = @filesize($tmpFile);
if ($size === 0 || $size === false) {
throw new ValidationException('Die hochgeladene Datei ist leer.');
}
$zip = new \ZipArchive();
$result = $zip->open($tmpFile);
if ($result !== true) {
+171
View File
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
use Psr\Log\LoggerInterface;
/**
* Service for generating inventory reports.
*
* Part of Issue #165 (Inventory Tracking).
*/
class InventoryReportService {
private GeneralMaterialMapper $generalMaterialMapper;
private InventoryCategoryMapper $categoryMapper;
private InventoryItemCategoryMapper $itemCategoryMapper;
private StockItemMapper $stockItemMapper;
private StockVariantMapper $stockVariantMapper;
private SaleRecordMapper $saleRecordMapper;
private LoggerInterface $logger;
public function __construct(
GeneralMaterialMapper $generalMaterialMapper,
InventoryCategoryMapper $categoryMapper,
InventoryItemCategoryMapper $itemCategoryMapper,
StockItemMapper $stockItemMapper,
StockVariantMapper $stockVariantMapper,
SaleRecordMapper $saleRecordMapper,
LoggerInterface $logger
) {
$this->generalMaterialMapper = $generalMaterialMapper;
$this->categoryMapper = $categoryMapper;
$this->itemCategoryMapper = $itemCategoryMapper;
$this->stockItemMapper = $stockItemMapper;
$this->stockVariantMapper = $stockVariantMapper;
$this->saleRecordMapper = $saleRecordMapper;
$this->logger = $logger;
}
/**
* Generate condition report data.
*
* @return array{summary: array, rows: array, headers: string[]}
*/
public function generateConditionReport(): array {
$items = $this->generalMaterialMapper->findAll();
// Build summary by category
$summary = [];
foreach ($items as $item) {
$catIds = $this->itemCategoryMapper->findByItemId($item->getId());
if (count($catIds) === 0) {
$catName = '(keine Kategorie)';
} else {
$catNames = [];
foreach ($catIds as $cat) {
try {
$c = $this->categoryMapper->findById((int)$cat->getCategoryId());
$catNames[] = $c->getName();
} catch (\Exception $e) {
// ignore
}
}
$catName = implode(', ', $catNames);
}
if (!isset($summary[$catName])) {
$summary[$catName] = 0;
}
$summary[$catName] += 1;
}
// Build detail rows for items needing repair (condition ≤ 2 or NULL)
$rows = [];
$needingRepair = $this->generalMaterialMapper->findNeedingRepair();
foreach ($needingRepair as $item) {
$catIds = $this->itemCategoryMapper->findByItemId($item->getId());
$catNames = [];
foreach ($catIds as $cat) {
try {
$c = $this->categoryMapper->findById((int)$cat->getCategoryId());
$catNames[] = $c->getName();
} catch (\Exception $e) {
// ignore
}
}
$conditionLabel = $item->getCondition() === null
? 'Nicht bewertet'
: $item->getCondition() . '/5';
$rows[] = [
'name' => $item->getName(),
'condition' => $conditionLabel,
'categories' => implode(', ', $catNames) ?: '',
'notes' => $item->getNotes() ?? '',
];
}
return [
'summary' => $summary,
'rows' => $rows,
'headers' => ['Artikel', 'Zustand', 'Kategorie', 'Notizen'],
];
}
/**
* Generate sales report data.
*
* @param string|null $dateFrom Start date (YYYY-MM-DD)
* @param string|null $dateTo End date (YYYY-MM-DD)
* @return array{title: string, summary: array, rows: array, headers: string[]}
*/
public function generateSalesReport(?string $dateFrom = null, ?string $dateTo = null): array {
$sales = $this->saleRecordMapper->findByDateRange($dateFrom, $dateTo);
$rows = [];
$totalRevenue = 0.0;
foreach ($sales as $sale) {
$itemName = 'Unbekannt';
try {
$stockItem = $this->stockItemMapper->findById((int)$sale->getStockItemId());
$itemName = $stockItem->getName();
} catch (\Exception $e) {
// ignore, keep default name
}
$variantName = '';
if ($sale->getVariantId() !== null) {
try {
$variant = $this->stockVariantMapper->findById((int)$sale->getVariantId());
$variantName = ' ' . $variant->getLabel();
} catch (\Exception $e) {
// ignore
}
}
$totalRevenue += (float)$sale->getTotalPrice();
$rows[] = [
'date' => $sale->getDate(),
'item' => $itemName . $variantName,
'quantity' => (string)$sale->getQuantity(),
'unit_price' => number_format((float)$sale->getUnitPrice(), 2, ',', '.') . ' €',
'total_price' => number_format((float)$sale->getTotalPrice(), 2, ',', '.') . ' €',
];
}
$title = 'Inventur-Verkäufe';
if ($dateFrom !== null || $dateTo !== null) {
$title .= ' (' . ($dateFrom ?? 'Anfang') . ' ' . ($dateTo ?? 'Ende') . ')';
}
return [
'title' => $title,
'summary' => [
'total_sales' => count($sales),
'total_revenue' => number_format($totalRevenue, 2, ',', '.') . ' €',
],
'rows' => $rows,
'headers' => ['Datum', 'Artikel', 'Menge', 'Stückpreis', 'Gesamtsumme'],
];
}
}
+486
View File
@@ -0,0 +1,486 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use DateTime;
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCA\Mitgliederverwaltung\Db\StockItem;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCA\Mitgliederverwaltung\Db\StockVariant;
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\DB\Exception;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* Service layer for inventory categories, general material, and stock items.
*
* Part of Issue #165 (Inventory Tracking).
*/
class InventoryService {
private InventoryCategoryMapper $categoryMapper;
private InventoryItemCategoryMapper $itemCategoryMapper;
private GeneralMaterialMapper $generalMaterialMapper;
private StockItemMapper $stockItemMapper;
private StockVariantMapper $stockVariantMapper;
private AuditService $auditService;
private LoggerInterface $logger;
public function __construct(
InventoryCategoryMapper $categoryMapper,
InventoryItemCategoryMapper $itemCategoryMapper,
GeneralMaterialMapper $generalMaterialMapper,
StockItemMapper $stockItemMapper,
StockVariantMapper $stockVariantMapper,
AuditService $auditService,
LoggerInterface $logger
) {
$this->categoryMapper = $categoryMapper;
$this->itemCategoryMapper = $itemCategoryMapper;
$this->generalMaterialMapper = $generalMaterialMapper;
$this->stockItemMapper = $stockItemMapper;
$this->stockVariantMapper = $stockVariantMapper;
$this->auditService = $auditService;
$this->logger = $logger;
}
// ── Categories ────────────────────────────────────────────────────
/**
* @return InventoryCategory[]
* @throws Exception
*/
public function getCategories(): array {
return $this->categoryMapper->findAll();
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function getCategory(int $id): InventoryCategory {
return $this->categoryMapper->findById($id);
}
/**
* @throws Exception
*/
public function createCategory(array $data): InventoryCategory {
$category = new InventoryCategory();
$category->setName($data['name'] ?? '');
$category->setCreatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->categoryMapper->insert($category);
$this->auditService->logCreate([
'name' => $category->getName(),
], 'inventory_category', $category->getId());
return $category;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function updateCategory(int $id, array $data): InventoryCategory {
$category = $this->categoryMapper->findById($id);
$oldData = ['name' => $category->getName()];
if (isset($data['name'])) {
$category->setName($data['name']);
}
$this->categoryMapper->update($category);
$this->auditService->logUpdate($oldData, [
'name' => $category->getName(),
], 'inventory_category', $id);
return $category;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function deleteCategory(int $id): void {
$category = $this->categoryMapper->findById($id);
$this->categoryMapper->delete($category);
$this->auditService->logDelete('inventory_category', $id);
}
// ── General Material ──────────────────────────────────────────────
/**
* @return GeneralMaterial[]
* @throws Exception
*/
public function getGeneralMaterial(): array {
return $this->generalMaterialMapper->findAll();
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function getGeneralMaterialById(int $id): GeneralMaterial {
return $this->generalMaterialMapper->findById($id);
}
/**
* @throws Exception
*/
public function createGeneralMaterial(array $data, ?array $categoryIds = null): GeneralMaterial {
$now = (new DateTime())->format('Y-m-d H:i:s');
$item = new GeneralMaterial();
$item->setName($data['name'] ?? '');
$item->setCondition($data['condition'] ?? null);
$item->setNotes($data['notes'] ?? null);
$item->setCreatedAt($now);
$item->setUpdatedAt($now);
$this->generalMaterialMapper->insert($item);
// Attach categories
if ($categoryIds !== null && is_array($categoryIds)) {
foreach ($categoryIds as $catId) {
try {
$this->itemCategoryMapper->attach($item->getId(), (int)$catId);
} catch (\Exception $e) {
$this->logger->warning('Failed to attach category {catId} to item {itemId}', [
'catId' => $catId,
'itemId' => $item->getId(),
'app' => 'mitgliederverwaltung',
]);
}
}
}
$this->auditService->logCreate([
'name' => $item->getName(),
'condition' => $item->getCondition(),
'notes' => $item->getNotes(),
], 'inventory_general_material', $item->getId());
return $item;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function updateGeneralMaterial(int $id, array $data, ?array $categoryIds = null): GeneralMaterial {
$item = $this->generalMaterialMapper->findById($id);
$oldData = [
'name' => $item->getName(),
'condition' => $item->getCondition(),
'notes' => $item->getNotes(),
];
if (isset($data['name'])) {
$item->setName($data['name']);
}
if (array_key_exists('condition', $data)) {
$item->setCondition($data['condition'] === null ? null : (int)$data['condition']);
}
if (array_key_exists('notes', $data)) {
$item->setNotes($data['notes'] === null ? null : (string)$data['notes']);
}
$item->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->generalMaterialMapper->update($item);
// Sync categories if provided
if ($categoryIds !== null && is_array($categoryIds)) {
// Detach all existing categories and re-attach
$existing = $this->itemCategoryMapper->findByItemId($id);
foreach ($existing as $existingCat) {
$this->itemCategoryMapper->detach($id, (int)$existingCat->getCategoryId());
}
foreach ($categoryIds as $catId) {
try {
$this->itemCategoryMapper->attach($id, (int)$catId);
} catch (\Exception $e) {
$this->logger->warning('Failed to attach category {catId} to item {itemId}', [
'catId' => $catId,
'itemId' => $id,
'app' => 'mitgliederverwaltung',
]);
}
}
}
$this->auditService->logUpdate($oldData, [
'name' => $item->getName(),
'condition' => $item->getCondition(),
'notes' => $item->getNotes(),
], 'inventory_general_material', $id);
return $item;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function deleteGeneralMaterial(int $id): void {
$item = $this->generalMaterialMapper->findById($id);
$this->generalMaterialMapper->delete($item);
$this->auditService->logDelete('inventory_general_material', $id);
}
/**
* Get items needing repair (condition ≤ 2 or NULL).
*
* @return GeneralMaterial[]
* @throws Exception
*/
public function getNeedingRepair(): array {
return $this->generalMaterialMapper->findNeedingRepair();
}
/**
* @return GeneralMaterial[]
* @throws Exception
*/
public function getGeneralMaterialByConditionRange(?int $min, ?int $max): array {
return $this->generalMaterialMapper->findByConditionRange($min, $max);
}
/**
* Get category names for an item.
*
* @return string[]
* @throws Exception
*/
public function getCategoryNamesForItem(int $itemId): array {
$cats = $this->itemCategoryMapper->findByItemId($itemId);
$names = [];
foreach ($cats as $cat) {
try {
$c = $this->categoryMapper->findById((int)$cat->getCategoryId());
$names[] = $c->getName();
} catch (\Exception $e) {
// ignore
}
}
return $names;
}
// ── Stock Items ───────────────────────────────────────────────────
/**
* @return StockItem[]
* @throws Exception
*/
public function getStockItems(): array {
return $this->stockItemMapper->findAll();
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function getStockItemById(int $id): StockItem {
return $this->stockItemMapper->findById($id);
}
/**
* @throws Exception
*/
public function createStockItem(array $data, ?array $categoryIds = null): StockItem {
$now = (new DateTime())->format('Y-m-d H:i:s');
$item = new StockItem();
$item->setName($data['name'] ?? '');
$item->setProviderUrlsJson($data['provider_urls_json'] ?? null);
$item->setCreatedAt($now);
$item->setUpdatedAt($now);
$this->stockItemMapper->insert($item);
// Attach categories
if ($categoryIds !== null && is_array($categoryIds)) {
foreach ($categoryIds as $catId) {
try {
$this->itemCategoryMapper->attach($item->getId(), (int)$catId);
} catch (\Exception $e) {
$this->logger->warning('Failed to attach category {catId} to stock item {itemId}', [
'catId' => $catId,
'itemId' => $item->getId(),
'app' => 'mitgliederverwaltung',
]);
}
}
}
$this->auditService->logCreate([
'name' => $item->getName(),
], 'inventory_stock_item', $item->getId());
return $item;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function updateStockItem(int $id, array $data, ?array $categoryIds = null): StockItem {
$item = $this->stockItemMapper->findById($id);
$oldData = [
'name' => $item->getName(),
'provider_urls_json' => $item->getProviderUrlsJson(),
];
if (isset($data['name'])) {
$item->setName($data['name']);
}
if (array_key_exists('provider_urls_json', $data)) {
$item->setProviderUrlsJson($data['provider_urls_json'] ?? null);
}
$item->setUpdatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->stockItemMapper->update($item);
// Sync categories if provided
if ($categoryIds !== null && is_array($categoryIds)) {
$existing = $this->itemCategoryMapper->findByItemId($id);
foreach ($existing as $existingCat) {
$this->itemCategoryMapper->detach($id, (int)$existingCat->getCategoryId());
}
foreach ($categoryIds as $catId) {
try {
$this->itemCategoryMapper->attach($id, (int)$catId);
} catch (\Exception $e) {
$this->logger->warning('Failed to attach category {catId} to stock item {itemId}', [
'catId' => $catId,
'itemId' => $id,
'app' => 'mitgliederverwaltung',
]);
}
}
}
$this->auditService->logUpdate($oldData, [
'name' => $item->getName(),
'provider_urls_json' => $item->getProviderUrlsJson(),
], 'inventory_stock_item', $id);
return $item;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function deleteStockItem(int $id): void {
$item = $this->stockItemMapper->findById($id);
$this->stockItemMapper->delete($item);
$this->auditService->logDelete('inventory_stock_item', $id);
}
// ── Stock Variants ────────────────────────────────────────────────
/**
* @throws Exception
*/
public function createStockVariant(array $data): StockVariant {
$variant = new StockVariant();
$variant->setStockItemId((int)$data['stock_item_id']);
$variant->setLabel($data['label'] ?? '');
$variant->setAmount((int)($data['amount'] ?? 0));
$variant->setCost((string)($data['cost'] ?? '0.00'));
$variant->setMinThreshold((int)($data['min_threshold'] ?? 0));
$this->stockVariantMapper->insert($variant);
$this->auditService->logCreate([
'label' => $variant->getLabel(),
'amount' => $variant->getAmount(),
'cost' => $variant->getCost(),
], 'inventory_stock_variant', $variant->getId());
return $variant;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function updateStockVariant(int $id, array $data): StockVariant {
$variant = $this->stockVariantMapper->findById($id);
$oldData = [
'label' => $variant->getLabel(),
'amount' => $variant->getAmount(),
'cost' => $variant->getCost(),
'min_threshold' => $variant->getMinThreshold(),
];
if (isset($data['label'])) {
$variant->setLabel($data['label']);
}
if (array_key_exists('amount', $data)) {
$variant->setAmount((int)$data['amount']);
}
if (array_key_exists('cost', $data)) {
$variant->setCost((string)$data['cost']);
}
if (array_key_exists('min_threshold', $data)) {
$variant->setMinThreshold((int)$data['min_threshold']);
}
$this->stockVariantMapper->update($variant);
$this->auditService->logUpdate($oldData, [
'label' => $variant->getLabel(),
'amount' => $variant->getAmount(),
'cost' => $variant->getCost(),
'min_threshold' => $variant->getMinThreshold(),
], 'inventory_stock_variant', $id);
return $variant;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function deleteStockVariant(int $id): void {
$variant = $this->stockVariantMapper->findById($id);
$this->stockVariantMapper->delete($variant);
$this->auditService->logDelete('inventory_stock_variant', $id);
}
/**
* @return StockVariant[]
* @throws Exception
*/
public function getVariantsByStockItem(int $stockItemId): array {
return $this->stockVariantMapper->findByStockItemId($stockItemId);
}
/**
* Check if an item needs repair (condition ≤ 2).
*/
public function needsRepair(?int $condition): bool {
return $condition === null || $condition <= 2;
}
/**
* Get condition label for display.
*/
public function getConditionLabel(?int $condition): string {
if ($condition === null) {
return 'Nicht bewertet';
}
return $condition . '/5';
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use DateTime;
use OCA\Mitgliederverwaltung\Db\SaleRecord;
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception;
use Psr\Log\LoggerInterface;
/**
* Service layer for inventory sale records.
*
* Part of Issue #165 (Inventory Tracking).
*/
class SaleService {
private SaleRecordMapper $saleRecordMapper;
private AuditService $auditService;
private LoggerInterface $logger;
public function __construct(
SaleRecordMapper $saleRecordMapper,
AuditService $auditService,
LoggerInterface $logger
) {
$this->saleRecordMapper = $saleRecordMapper;
$this->auditService = $auditService;
$this->logger = $logger;
}
/**
* @return SaleRecord[]
* @throws Exception
*/
public function getSales(): array {
return $this->saleRecordMapper->findAll();
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function getSaleById(int $id): SaleRecord {
return $this->saleRecordMapper->findById($id);
}
/**
* @throws Exception
*/
public function createSale(array $data): SaleRecord {
$qty = (int)($data['quantity'] ?? 0);
$unitPrice = (string)($data['unit_price'] ?? '0.00');
// Calculate total price = quantity × unit_price
$totalPrice = number_format((float)$unitPrice * $qty, 2, '.', '');
$record = new SaleRecord();
$record->setStockItemId($data['stock_item_id'] ?? null);
$record->setVariantId($data['variant_id'] ?? null);
$record->setDate($data['date'] ?? (new DateTime())->format('Y-m-d'));
$record->setQuantity($qty);
$record->setUnitPrice($unitPrice);
$record->setTotalPrice($totalPrice);
$record->setNotes($data['notes'] ?? null);
$record->setCreatedAt((new DateTime())->format('Y-m-d H:i:s'));
$this->saleRecordMapper->insert($record);
$this->auditService->logCreate([
'stock_item_id' => $record->getStockItemId(),
'variant_id' => $record->getVariantId(),
'date' => $record->getDate(),
'quantity' => $record->getQuantity(),
'unit_price' => $record->getUnitPrice(),
'total_price' => $record->getTotalPrice(),
], 'inventory_sale', $record->getId());
return $record;
}
/**
* @throws DoesNotExistException
* @throws Exception
*/
public function deleteSale(int $id): void {
$record = $this->saleRecordMapper->findById($id);
$this->saleRecordMapper->delete($record);
$this->auditService->logDelete('inventory_sale', $id);
}
/**
* @return SaleRecord[]
* @throws Exception
*/
public function getSalesByDateRange(?string $from, ?string $to): array {
return $this->saleRecordMapper->findByDateRange($from, $to);
}
/**
* @throws Exception
*/
public function countSalesByDateRange(?string $from, ?string $to): int {
return $this->saleRecordMapper->countByDateRange($from, $to);
}
}
+387
View File
@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCA\Mitgliederverwaltung\Db\FamilyMapper;
use OCA\Mitgliederverwaltung\Db\FeeRecordMapper;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InjuryMapper;
use OCA\Mitgliederverwaltung\Db\LagerMapper;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCP\DB\Exception;
use OCP\IDBConnection;
/**
* Orchestrates unified search across all entity categories.
*
* Part of Issue #200.
*/
class UnifiedSearchService {
private MemberMapper $memberMapper;
private FamilyMapper $familyMapper;
private LagerMapper $lagerMapper;
private GeneralMaterialMapper $generalMaterialMapper;
private StockItemMapper $stockItemMapper;
private InjuryMapper $injuryMapper;
private FeeRecordMapper $feeRecordMapper;
/**
* Allowed categories for the search endpoint.
*/
private const ALLOWED_CATEGORIES = [
'mitglieder',
'familien',
'lager',
'inventar',
'unfalle',
'beitrage',
'alle',
];
public function __construct(
IDBConnection $db
) {
$this->memberMapper = new MemberMapper($db);
$this->familyMapper = new FamilyMapper($db);
$this->lagerMapper = new LagerMapper($db);
$this->generalMaterialMapper = new GeneralMaterialMapper($db);
$this->stockItemMapper = new StockItemMapper($db);
$this->injuryMapper = new InjuryMapper($db);
$this->feeRecordMapper = new FeeRecordMapper($db);
}
/**
* Search across one or all categories.
*
* @param string $query Search query (≥ 2 characters)
* @param string $category One of the ALLOWED_CATEGORIES
* @param int $limit Max results per category
* @return array Grouped by category
*/
public function search(string $query, string $category, int $limit = 10): array {
if (!in_array($category, self::ALLOWED_CATEGORIES, true)) {
$category = 'alle';
}
$result = [
'mitglieder' => [],
'familien' => [],
'lager' => [],
'inventar' => [],
'unfalle' => [],
'beitrage' => [],
];
$categories = $category === 'alle' ? self::ALLOWED_CATEGORIES : [$category];
foreach ($categories as $cat) {
if ($cat === 'mitglieder') {
$result['mitglieder'] = $this->searchMitglieder($query, $limit);
} elseif ($cat === 'familien') {
$result['familien'] = $this->searchFamilien($query, $limit);
} elseif ($cat === 'lager') {
$result['lager'] = $this->searchLager($query, $limit);
} elseif ($cat === 'inventar') {
$result['inventar'] = $this->searchInventar($query, $limit);
} elseif ($cat === 'unfalle') {
$result['unfalle'] = $this->searchUnfalle($query, $limit);
} elseif ($cat === 'beitrage') {
$result['beitrage'] = $this->searchBeitraege($query, $limit);
}
}
return $result;
}
/**
* Search members.
*
* @return array List of search result items
*/
private function searchMitglieder(string $query, int $limit): array {
try {
$members = $this->memberMapper->fullTextSearch($query, $limit);
} catch (Exception $e) {
return [];
}
return array_map(function ($member) use ($query) {
$vorname = $member->getVorname() ?? '';
$nachname = $member->getNachname() ?? '';
$title = $nachname . ', ' . $vorname;
$status = $member->getStatus() ?? '';
$rolle = $member->getRolle() ?? '';
$subtitleParts = [];
if ($rolle === 'erziehungsberechtigter') {
$subtitleParts[] = 'Erziehungsberechtigt';
} elseif ($rolle === 'vorstand') {
$subtitleParts[] = 'Vorstand';
}
if ($status === 'aktiv') {
$subtitleParts[] = 'Aktiv';
} elseif ($status === 'inaktiv') {
$subtitleParts[] = 'Inaktiv';
}
$subtitle = implode(' • ', $subtitleParts);
return [
'id' => (int)$member->getId(),
'category' => 'mitglieder',
'title' => $title,
'subtitle' => $subtitle,
'highlight' => $this->generateHighlight($title, $subtitle, $query, [0, strlen($title)]),
'route' => [
'name' => 'member-detail',
'params' => ['id' => (int)$member->getId()],
],
];
}, $members);
}
/**
* Search families.
*
* @return array List of search result items
*/
private function searchFamilien(string $query, int $limit): array {
try {
$families = $this->familyMapper->findByName($query);
} catch (Exception $e) {
return [];
}
return array_map(function ($family) use ($query) {
$name = $family->getName() ?? '';
// Count members for subtitle — we'll need to count from related data
// For now, show a generic count
$title = 'Familie ' . $name;
return [
'id' => (int)$family->getId(),
'category' => 'familien',
'title' => $title,
'subtitle' => 'Familie',
'highlight' => $this->generateHighlight($title, 'Familie', $query, [0, strlen($name) + 8]),
'route' => [
'name' => 'family-detail',
'params' => ['id' => (int)$family->getId()],
],
];
}, $families);
}
/**
* Search camps (Lager).
*
* @return array List of search result items
*/
private function searchLager(string $query, int $limit): array {
try {
$camps = $this->lagerMapper->search($query, $limit);
} catch (Exception $e) {
return [];
}
return array_map(function ($camp) use ($query) {
$name = $camp->getName() ?? '';
$title = $name;
// Subtitle: start date or ort
$subtitle = '';
if ($camp->getOrt()) {
$subtitle = $camp->getOrt();
}
return [
'id' => (int)$camp->getId(),
'category' => 'lager',
'title' => $title,
'subtitle' => $subtitle,
'highlight' => $this->generateHighlight($title, $subtitle, $query, [0, strlen($name)]),
'route' => [
'name' => 'lager-detail',
'params' => ['id' => (int)$camp->getId()],
],
];
}, $camps);
}
/**
* Search inventory (general material + stock items).
*
* @return array List of search result items
*/
private function searchInventar(string $query, int $limit): array {
$results = [];
// Search general material
try {
$general = $this->generalMaterialMapper->search($query, (int)ceil($limit / 2));
foreach ($general as $item) {
$name = $item->getName() ?? '';
$results[] = [
'id' => (int)$item->getId(),
'category' => 'inventar',
'title' => $name,
'subtitle' => 'Allgemeinmaterial',
'highlight' => $this->generateHighlight($name, 'Allgemeinmaterial', $query, [0, strlen($name)]),
'route' => [
'name' => 'inventory',
'params' => [],
],
];
}
} catch (Exception $e) {
// ignore
}
// Search stock items
try {
$stock = $this->stockItemMapper->search($query, $limit);
foreach ($stock as $item) {
$name = $item->getName() ?? '';
$results[] = [
'id' => (int)$item->getId(),
'category' => 'inventar',
'title' => $name,
'subtitle' => 'Verkaufsmaterial',
'highlight' => $this->generateHighlight($name, 'Verkaufsmaterial', $query, [0, strlen($name)]),
'route' => [
'name' => 'inventory',
'params' => [],
],
];
}
} catch (Exception $e) {
// ignore
}
return $results;
}
/**
* Search injury records.
*
* @return array List of search result items
*/
private function searchUnfalle(string $query, int $limit): array {
try {
$injuries = $this->injuryMapper->search($query, $limit);
} catch (Exception $e) {
return [];
}
return array_map(function ($injury) use ($query) {
$beschreibung = $injury->getBeschreibung() ?? '';
$memberVorname = $injury->member_vorname ?? '';
$memberNachname = $injury->member_nachname ?? '';
$title = $beschreibung;
$subtitle = '';
if ($memberVorname && $memberNachname) {
$subtitle = $memberVorname . ' ' . $memberNachname;
}
return [
'id' => (int)$injury->getId(),
'category' => 'unfalle',
'title' => $title,
'subtitle' => $subtitle,
'highlight' => $this->generateHighlight($title, $subtitle, $query, [0, strlen($beschreibung)]),
'route' => [
'name' => 'injuries',
'params' => [],
],
];
}, $injuries);
}
/**
* Search fee records.
*
* @return array List of search result items
*/
private function searchBeitraege(string $query, int $limit): array {
try {
$records = $this->feeRecordMapper->search($query, $limit);
} catch (Exception $e) {
return [];
}
return array_map(function ($record) use ($query) {
$memberVorname = $record->member_vorname ?? '';
$memberNachname = $record->member_nachname ?? '';
$year = $record->getYear() ?? 0;
$title = 'Beitrag ' . $year;
$subtitle = '';
if ($memberVorname && $memberNachname) {
$subtitle = $memberVorname . ' ' . $memberNachname;
}
return [
'id' => (int)$record->getId(),
'category' => 'beitrage',
'title' => $title,
'subtitle' => $subtitle,
'highlight' => $this->generateHighlight($title, $subtitle, $query, [0, strlen($title)]),
'route' => [
'name' => 'fees',
'params' => [],
],
];
}, $records);
}
/**
* Generate highlighted title/subtitle with <mark> tags around matched portions.
*
* @param string $title Raw title
* @param string $subtitle Raw subtitle
* @param string $query The search query (lowercase)
* @param array $highlightRange ['start', 'length'] for where the match is
* @return array ['title' => string, 'subtitle' => string]
*/
private function generateHighlight(string $title, string $subtitle, string $query, array $highlightRange): array {
$highlightTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$highlightSubtitle = htmlspecialchars($subtitle, ENT_QUOTES, 'UTF-8');
if ($query === '') {
return ['title' => $highlightTitle, 'subtitle' => $highlightSubtitle];
}
// Find the query in the title and wrap in <mark>
$titleLow = mb_strtolower($title);
$queryLow = mb_strtolower($query);
$pos = mb_stripos($title, $queryLow);
if ($pos !== false && $pos !== false) {
$before = mb_substr($title, 0, $pos);
$match = mb_substr($title, $pos, mb_strlen($query));
$after = mb_substr($title, $pos + mb_strlen($query));
$highlightTitle = htmlspecialchars($before, ENT_QUOTES, 'UTF-8')
. '<mark>' . htmlspecialchars($match, ENT_QUOTES, 'UTF-8') . '</mark>'
. htmlspecialchars($after, ENT_QUOTES, 'UTF-8');
}
// Find the query in the subtitle
$subLow = mb_strtolower($subtitle);
$subPos = mb_stripos($subtitle, $queryLow);
if ($subPos !== false && $subPos !== false) {
$subBefore = mb_substr($subtitle, 0, $subPos);
$subMatch = mb_substr($subtitle, $subPos, mb_strlen($query));
$subAfter = mb_substr($subtitle, $subPos + mb_strlen($query));
$highlightSubtitle = htmlspecialchars($subBefore, ENT_QUOTES, 'UTF-8')
. '<mark>' . htmlspecialchars($subMatch, ENT_QUOTES, 'UTF-8') . '</mark>'
. htmlspecialchars($subAfter, ENT_QUOTES, 'UTF-8');
}
return [
'title' => $highlightTitle,
'subtitle' => $highlightSubtitle,
];
}
}
+25 -3
View File
@@ -30,6 +30,13 @@
<Tent :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem name="Inventar"
:to="{ name: 'inventory' }"
:active="currentRoute === 'inventory'">
<template #icon>
<PackageVariant :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem name="Verletzungen"
:to="{ name: 'injuries' }"
:active="currentRoute === 'injuries'">
@@ -83,7 +90,7 @@
</NcAppNavigation>
<NcAppContent>
<div class="app-search-header">
<SearchBar />
<SearchBar ref="searchBarRef" :previousView="searchPreviousView" @update:previousView="onSearchViewChange" />
</div>
<router-view />
</NcAppContent>
@@ -91,8 +98,8 @@
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NcContent, NcAppContent, NcAppNavigation, NcAppNavigationItem } from '@nextcloud/vue'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
@@ -103,13 +110,28 @@ import Cog from 'vue-material-design-icons/Cog.vue'
import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue'
import SwapVertical from 'vue-material-design-icons/SwapVertical.vue'
import MedicalBag from 'vue-material-design-icons/MedicalBag.vue'
import PackageVariant from 'vue-material-design-icons/PackageVariant.vue'
import Tent from 'vue-material-design-icons/Campfire.vue'
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
import SearchBar from './components/SearchBar.vue'
const route = useRoute()
const router = useRouter()
const searchBarRef = ref(null)
const searchPreviousView = ref(null)
const currentRoute = computed(() => route.name)
function onSearchViewChange(view) {
searchPreviousView.value = view
}
// Clear search breadcrumb when navigating away from search context
router.beforeEach(() => {
if (searchPreviousView.value) {
searchPreviousView.value = null
}
})
</script>
<style scoped>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="category-picker">
<NcSelect
:model-value="selectedCategories"
:options="options"
multiple
:reduce="o => o.value"
:close-on-select="false"
placeholder="Kategorien auswählen..."
@update:model-value="$emit('update:modelValue', $event.map(o => o.value))"
/>
<div class="category-picker__create">
<NcTextField
:model-value="newCategoryName"
placeholder="Neue Kategorie erstellen..."
@update:model-value="newCategoryName = $event"
/>
<NcButton
type="primary"
:disabled="!newCategoryName"
@click="createNewCategory"
>
<template #icon>
<Plus :size="20" />
</template>
Erstellen
</NcButton>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { NcSelect, NcButton } from '@nextcloud/vue'
import Plus from 'vue-material-design-icons/Plus.vue'
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
options: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue', 'create'])
const newCategoryName = ref('')
const selectedCategories = ref([...(props.modelValue || [])])
// Keep selectedCategories in sync with modelValue
watch(() => props.modelValue, (newVal) => {
selectedCategories.value = [...(newVal || [])]
})
function createNewCategory() {
if (!newCategoryName.value) return
emit('create', newCategoryName.value.trim())
newCategoryName.value = ''
}
</script>
<style scoped>
.category-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-picker__create {
display: flex;
gap: 8px;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="general-material-form">
<form @submit.prevent="handleSubmit">
<div class="general-material-form__field">
<label for="gm-name">Name:</label>
<NcTextField
id="gm-name"
:model-value="form.name"
:placeholder="'Name des Materials'"
:validate="form.name !== ''"
@update:model-value="form.name = $event"
required
/>
</div>
<div class="general-material-form__field">
<label for="gm-condition">Zustand (05):</label>
<NcSelect
id="gm-condition"
:model-value="conditionOptions.find(o => o.value === form.condition)"
:options="conditionOptions"
:reduce="o => o.value"
:clearable="true"
@update:model-value="form.condition = $event"
>
<template #cell="{ option }">
<span class="general-material-form__condition-option">
{{ option.label }}
</span>
</template>
</NcSelect>
</div>
<div class="general-material-form__field">
<label for="gm-categories">Kategorien:</label>
<NcSelect
id="gm-categories"
:model-value="categoryOptions.filter(o => form.categories?.includes(o.value))"
:options="categoryOptions"
multiple
:reduce="o => o.value"
:close-on-select="false"
@update:model-value="form.categories = $event.map(o => o.value)"
/>
</div>
<div class="general-material-form__field">
<label for="gm-notes">Notizen:</label>
<NcTextField
id="gm-notes"
:type="'textarea'"
:model-value="form.notes"
:placeholder="'Zusätzliche Notizen...'"
:multiple-lines="true"
@update:model-value="form.notes = $event"
/>
</div>
<div class="general-material-form__actions">
<NcButton type="tertiary" @click="$emit('close')">
Abbrechen
</NcButton>
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
{{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }}
</NcButton>
</div>
</form>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { NcTextField, NcSelect, NcButton } from '@nextcloud/vue'
const props = defineProps({
item: {
type: Object,
default: null,
},
categories: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update', 'close'])
const editMode = computed(() => !!props.item)
const form = ref({
name: '',
condition: null,
categories: [],
notes: '',
})
const conditionOptions = computed(() => {
const options = []
for (let i = 0; i <= 5; i++) {
options.push({ value: i, label: i === 0 ? '0 (sehr schlecht)' : i === 5 ? '5 (hervorragend)' : String(i) })
}
return options
})
const categoryOptions = computed(() => {
return props.categories.map(c => ({
value: c.id,
label: c.name,
}))
})
const isFormValid = computed(() => form.value.name !== '')
// Load existing item data
watch(() => props.item, (newVal) => {
if (newVal) {
form.value = {
name: newVal.name || '',
condition: newVal.condition ?? null,
categories: newVal.categories || [],
notes: newVal.notes || '',
}
}
}, { immediate: true })
async function handleSubmit() {
if (!isFormValid.value) return
const data = {
name: form.value.name,
condition: form.value.condition,
categories: form.value.categories,
notes: form.value.notes || null,
}
if (editMode.value) {
await emit('update', 'general', 'update', { id: props.item.id, ...data })
} else {
await emit('update', 'general', 'create', data)
}
}
</script>
<style scoped>
.general-material-form {
max-width: 500px;
}
.general-material-form__field {
margin-bottom: 16px;
}
.general-material-form__field label {
display: block;
margin-bottom: 4px;
font-weight: 600;
font-size: 14px;
}
.general-material-form__condition-option {
font-size: 14px;
}
.general-material-form__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
</style>
+9 -34
View File
@@ -11,13 +11,6 @@
<NcButton @click="permStore.clearError()">Schließen</NcButton>
</div>
<!-- Search/Filter -->
<div class="permission-settings__filter">
<NcTextField :model-value="filterQuery"
placeholder="Benutzer filtern..."
@update:model-value="filterQuery = $event" />
</div>
<!-- Loading -->
<NcLoadingIcon v-if="permStore.loading && permStore.permissions.length === 0"
:size="32"
@@ -25,7 +18,7 @@
<!-- User list with permissions -->
<div v-else class="permission-settings__list">
<div v-for="userPerm in filteredUsers"
<div v-for="userPerm in allUsers"
:key="userPerm.uid"
class="permission-settings__user-row">
@@ -77,52 +70,39 @@
</div>
<!-- No users found -->
<p v-if="!permStore.loading && filteredUsers.length === 0" class="permission-settings__empty">
<p v-if="!permStore.loading && allUsers.length === 0" class="permission-settings__empty">
Keine Benutzer gefunden.
</p>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { NcButton, NcTextField, NcLoadingIcon } from '@nextcloud/vue'
import { computed, onMounted } from 'vue'
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
import { usePermissionsStore } from '../stores/permissions.js'
import { useStufenStore } from '../stores/stufen.js'
const permStore = usePermissionsStore()
const stufenStore = useStufenStore()
const filterQuery = ref('')
const stufen = computed(() => stufenStore.stufen)
/**
* Merge NC users with their permissions for display.
*/
const filteredUsers = computed(() => {
const allUsers = computed(() => {
const permMap = {}
for (const perm of permStore.permissions) {
permMap[perm.ncUserId] = perm
}
let users = permStore.users.map(user => ({
return permStore.users.map(user => ({
uid: user.uid,
displayName: user.displayName,
level: permMap[user.uid]?.level || 'none',
allowedStufen: permMap[user.uid]?.allowedStufen || [],
canSeeBanking: permMap[user.uid]?.canSeeBanking || false,
}))
// Filter by search query
if (filterQuery.value.trim()) {
const q = filterQuery.value.trim().toLowerCase()
users = users.filter(u =>
u.uid.toLowerCase().includes(q)
|| (u.displayName || '').toLowerCase().includes(q),
)
}
return users
})
onMounted(async () => {
@@ -137,7 +117,7 @@ onMounted(async () => {
* Handle permission level change.
*/
async function onLevelChange(uid, newLevel) {
const user = filteredUsers.value.find(u => u.uid === uid)
const user = allUsers.value.find(u => u.uid === uid)
if (!user) return
try {
@@ -156,7 +136,7 @@ async function onLevelChange(uid, newLevel) {
* Handle Stufen checkbox change.
*/
async function onStufenChange(uid, stufeId, checked) {
const user = filteredUsers.value.find(u => u.uid === uid)
const user = allUsers.value.find(u => u.uid === uid)
if (!user) return
let allowedStufen = [...(user.allowedStufen || [])]
@@ -184,7 +164,7 @@ async function onStufenChange(uid, stufeId, checked) {
* Handle banking visibility toggle.
*/
async function onBankingChange(uid, canSeeBanking) {
const user = filteredUsers.value.find(u => u.uid === uid)
const user = allUsers.value.find(u => u.uid === uid)
if (!user) return
try {
@@ -217,11 +197,6 @@ async function onBankingChange(uid, canSeeBanking) {
margin-bottom: 12px;
}
.permission-settings__filter {
margin-bottom: 16px;
max-width: 400px;
}
.permission-settings__loading {
margin: 20px 0;
}
+191
View File
@@ -0,0 +1,191 @@
<template>
<div class="sale-form">
<form @submit.prevent="handleSubmit">
<div class="sale-form__field">
<label for="sale-item">Artikel:</label>
<NcSelect
id="sale-item"
:model-value="stockItemOptions.find(o => o.value === form.stock_item_id)"
:options="stockItemOptions"
:reduce="o => o.value"
:clearable="true"
@update:model-value="form.stock_item_id = $event"
placeholder="Artikel auswählen..."
/>
</div>
<div class="sale-form__field">
<label for="sale-date">Datum:</label>
<NcDateTimePicker
id="sale-date"
:model-value="form.date ? new Date(form.date + 'T00:00:00') : new Date()"
@update:model-value="form.date = $event?.toISOString().split('T')[0] || (new Date()).toISOString().split('T')[0]"
/>
</div>
<div class="sale-form__field">
<label for="sale-variant">Variante:</label>
<NcSelect
id="sale-variant"
:model-value="variantOptions.find(o => o.value === form.variant_id)"
:options="variantOptions"
:reduce="o => o.value"
:clearable="true"
:disabled="!form.stock_item_id"
@update:model-value="form.variant_id = $event"
placeholder="Variante auswählen (optional)..."
/>
</div>
<div class="sale-form__field">
<label for="sale-quantity">Menge:</label>
<NcTextField
id="sale-quantity"
:type="'number'"
:model-value="form.quantity"
:validate="form.quantity >= 1"
@update:model-value="form.quantity = Number($event) || 0"
/>
</div>
<div class="sale-form__field">
<label for="sale-unit-price">Stückpreis (€):</label>
<NcTextField
id="sale-unit-price"
:type="'text'"
:model-value="form.unit_price"
:validate="form.unit_price !== ''"
@update:model-value="form.unit_price = $event"
placeholder="0.00"
/>
</div>
<div class="sale-form__field sale-form__field--summary">
<strong>Gesamtsumme:</strong> {{ totalPrice }}
</div>
<div class="sale-form__field">
<label for="sale-notes">Notizen:</label>
<NcTextField
id="sale-notes"
:type="'textarea'"
:model-value="form.notes"
:placeholder="'Zusätzliche Notizen...'"
:validate="true"
@update:model-value="form.notes = $event"
/>
</div>
<div class="sale-form__actions">
<NcButton type="tertiary" @click="$emit('close')">
Abbrechen
</NcButton>
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
Verkauf eintragen
</NcButton>
</div>
</form>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { NcTextField, NcSelect, NcButton, NcDateTimePicker } from '@nextcloud/vue'
const props = defineProps({
stockItems: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['close', 'create'])
const form = ref({
stock_item_id: null,
variant_id: null,
date: new Date().toISOString().split('T')[0],
quantity: 1,
unit_price: '0.00',
notes: '',
})
const stockItemOptions = computed(() => {
return props.stockItems.map(item => ({
value: item.id,
label: item.name,
}))
})
const variantOptions = computed(() => {
if (!form.value.stock_item_id) return []
const item = props.stockItems.find(i => i.id === form.value.stock_item_id)
if (!item || !item.variants) return []
return item.variants.map(v => ({
value: v.id,
label: v.label,
}))
})
const totalPrice = computed(() => {
const qty = Number(form.value.quantity) || 0
const price = parseFloat(form.value.unit_price) || 0
const total = qty * price
return total.toLocaleString('de-DE', {
style: 'currency',
currency: 'EUR',
})
})
const isFormValid = computed(() => {
return form.value.stock_item_id !== null &&
form.value.quantity >= 1 &&
form.value.unit_price !== ''
})
async function handleSubmit() {
if (!isFormValid.value) return
const data = {
stock_item_id: form.value.stock_item_id,
variant_id: form.value.variant_id,
date: form.value.date,
quantity: Number(form.value.quantity),
unit_price: form.value.unit_price,
notes: form.value.notes || null,
}
// Create the sale and notify the parent
emit('create', data)
}
</script>
<style scoped>
.sale-form {
max-width: 500px;
}
.sale-form__field {
margin-bottom: 16px;
}
.sale-form__field label {
display: block;
margin-bottom: 4px;
font-weight: 600;
font-size: 14px;
}
.sale-form__field--summary {
background: var(--color-background-dark);
padding: 12px;
border-radius: var(--border-radius);
}
.sale-form__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
</style>
+356 -127
View File
@@ -1,36 +1,69 @@
<template>
<div class="search-bar" :class="{ 'search-bar--open': showResults }">
<div
class="search-bar"
:class="{ 'search-bar--open': showDropdown }"
@mouseenter="isInteracting = true"
@mouseleave="onMouseLeave"
>
<div class="search-bar__input-wrapper">
<Magnify :size="18" class="search-bar__icon" />
<input ref="inputRef"
v-model="query"
<input
ref="inputRef"
:model-value="query"
type="text"
class="search-bar__input"
placeholder="Mitglieder suchen..."
:placeholder="placeholderText"
autocomplete="off"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown.escape="close"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
@keydown.enter.prevent="selectCurrent"
@keydown.escape="close">
<button v-if="query"
@update:model-value="onInput"
/>
<button
v-if="query"
class="search-bar__clear"
title="Leeren"
@mousedown.prevent="clearQuery">
@mousedown.prevent="clearQuery"
>
&#10005;
</button>
</div>
<!-- Results dropdown -->
<div v-if="showResults" class="search-bar__dropdown">
<!-- Breadcrumb back navigation -->
<div
v-if="previousView"
class="search-bar__breadcrumb"
>
<span class="search-bar__breadcrumb-link" @mousedown.prevent="goBack">
&#8592; Zurück zu {{ previousViewLabel }}
</span>
</div>
<!-- Dropdown -->
<div v-if="showDropdown" class="search-bar__dropdown">
<!-- Category chips -->
<div class="search-bar__chips">
<button
v-for="cat in categoryChips"
:key="cat.id"
:class="[
'search-bar__chip',
{ 'search-bar__chip--active': selectedCategory === cat.id }
]"
@mousedown.prevent="selectCategory(cat.id)"
>
{{ cat.label }}
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="search-bar__loading">
Suche...
</div>
<!-- Error (shown instead of "no results" when the request failed) -->
<!-- Error -->
<div v-else-if="error" class="search-bar__error">
<strong>Suche fehlgeschlagen</strong>
<div class="search-bar__error-msg">{{ error }}</div>
@@ -40,7 +73,10 @@
</div>
<!-- No results -->
<div v-else-if="results.length === 0 && query.trim().length >= 2" class="search-bar__empty">
<div
v-else-if="hasAnyResults && Object.values(results).every(arr => arr.length === 0)"
class="search-bar__empty"
>
Keine Ergebnisse für "{{ query }}"
</div>
@@ -49,91 +85,187 @@
Mindestens 2 Zeichen eingeben
</div>
<!-- Results list -->
<ul v-else class="search-bar__results">
<li v-for="(result, index) in results"
:key="result.id"
:class="{ 'search-bar__result--selected': index === selectedIndex }"
class="search-bar__result"
@mousedown.prevent="navigateToMember(result.id)"
@mouseenter="selectedIndex = index">
<div class="search-bar__result-name">
{{ result.nachname }}, {{ result.vorname }}
</div>
<div v-if="result.matchContext" class="search-bar__result-context">
{{ result.matchContext }}
</div>
<div class="search-bar__result-meta">
<span :class="'search-bar__status search-bar__status--' + result.status">
{{ formatStatus(result.status) }}
</span>
<span v-if="result.rolle === 'erziehungsberechtigter'" class="search-bar__rolle">
Erziehungsberechtigt
</span>
</div>
</li>
<!-- Results grouped by category -->
<ul v-else class="search-bar__results" role="listbox">
<template v-for="cat in activeCategoryChips" :key="cat.id">
<!-- Only show category header if there are results or if it's the selected category -->
<template
v-if="results[cat.id] && results[cat.id].length > 0"
>
<li class="search-bar__category-header">
{{ cat.label }}
</li>
<li
v-for="(result, index) in results[cat.id]"
:key="result.id + '-' + cat.id"
:class="{ 'search-bar__result--selected': isItemSelected(cat.id, index) }"
class="search-bar__result"
role="option"
@mousedown.prevent="navigateToResult(result)"
@mouseenter="selectedIndex = { category: cat.id, index }"
>
<div
class="search-bar__result-title"
v-html="result.highlight?.title || result.title"
/>
<div
v-if="result.highlight?.subtitle"
class="search-bar__result-subtitle"
v-html="result.highlight.subtitle"
/>
<div
v-else-if="result.subtitle"
class="search-bar__result-subtitle"
>
{{ result.subtitle }}
</div>
</li>
</template>
</template>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import Magnify from 'vue-material-design-icons/Magnify.vue'
const props = defineProps({
previousView: {
type: [Object, null],
default: null,
},
})
const emit = defineEmits(['update:previousView'])
const router = useRouter()
// ── State ────────────────────────────────────────────────────────────
const query = ref('')
const results = ref([])
const showDropdown = ref(false)
const loading = ref(false)
const error = ref(null)
const showResults = ref(false)
const selectedIndex = ref(-1)
const selectedIndex = ref({ category: null, index: -1 })
const inputRef = ref(null)
const isInteracting = ref(false)
const selectedCategory = ref('alle')
const results = ref({})
const previousView = ref(props.previousView)
let searchTimeout = null
let inflightRequestId = 0
// ── Search logic ────────────────────────────────────────────────────
// ── Category chips ───────────────────────────────────────────────────
const categoryChips = [
{ id: 'alle', label: 'Alle' },
{ id: 'mitglieder', label: 'Mitglieder' },
{ id: 'familien', label: 'Familien' },
{ id: 'lager', label: 'Lager' },
{ id: 'inventar', label: 'Inventar' },
{ id: 'unfalle', label: 'Unfälle' },
{ id: 'beitrage', label: 'Beiträge' },
]
function onInput() {
selectedIndex.value = -1
const activeCategoryChips = computed(() => {
if (selectedCategory.value === 'alle') {
return categoryChips.slice(0, 7)
}
return categoryChips.filter(c => c.id === selectedCategory.value || c.id === 'alle')
})
// ── Computed ─────────────────────────────────────────────────────────
const placeholderText = computed(() => {
if (selectedCategory.value === 'alle') return 'Suche...'
const cat = categoryChips.find(c => c.id === selectedCategory.value)
return cat ? `In ${cat.label} suchen...` : 'Suche...'
})
const hasAnyResults = computed(() => {
return Object.values(results.value).some(arr => arr.length > 0)
})
const previousViewLabel = computed(() => {
if (!previousView.value) return ''
const names = {
'members': 'Mitglieder',
'member-detail': 'Mitglied',
'families': 'Familien',
'family-detail': 'Familie',
'fees': 'Beiträge',
'lager': 'Lager',
'lager-detail': 'Lager',
'injuries': 'Verletzungen',
'inventory': 'Inventar',
'reports': 'Berichte',
'audit-log': 'Audit-Log',
'settings': 'Einstellungen',
'backup': 'Backup',
'import': 'Import / Export',
'queries': 'Abfragen',
}
return names[previousView.value.name] || previousView.value.name
})
// ── Input handling ────────────────────────────────────────────────────
function onInput(value) {
selectedIndex.value = { category: null, index: -1 }
error.value = null
clearTimeout(searchTimeout)
if (query.value.trim().length < 2) {
results.value = []
const trimmed = value.trim()
if (trimmed.length < 2) {
results.value = {}
clearTimeout(searchTimeout)
return
}
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
performSearch()
}, 300)
}
// ── Category selection ───────────────────────────────────────────────
function selectCategory(catId) {
selectedCategory.value = catId
// Don't clear the query when changing category
if (query.value.trim().length >= 2) {
performSearch()
}
}
// ── Search logic ──────────────────────────────────────────────────────
async function performSearch() {
if (query.value.trim().length < 2) return
const trimmedQuery = query.value.trim()
if (trimmedQuery.length < 2) return
const requestId = ++inflightRequestId
loading.value = true
error.value = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members/search')
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/search')
const response = await axios.get(url, {
params: { q: query.value.trim(), limit: 10 },
params: {
q: trimmedQuery,
category: selectedCategory.value,
limit: 10,
},
timeout: 10000,
})
// Drop stale responses — only the most recent request wins
// Drop stale responses
if (requestId !== inflightRequestId) return
results.value = response.data.data || []
results.value = response.data.data || {}
} catch (err) {
if (requestId !== inflightRequestId) return
console.error('Search failed:', err)
results.value = []
console.error('Unified search failed:', err)
results.value = {}
error.value = formatSearchError(err)
} finally {
if (requestId === inflightRequestId) {
@@ -166,11 +298,10 @@ function formatSearchError(err) {
return 'Unbekannter Fehler.'
}
// ── Dropdown visibility ─────────────────────────────────────────────
// ── Dropdown visibility ─────────────────────────────────────────────
function onFocus() {
showResults.value = true
if (query.value.trim().length >= 2 && results.value.length === 0) {
showDropdown.value = true
if (query.value.trim().length >= 2 && hasAnyResults.value) {
performSearch()
}
}
@@ -178,71 +309,141 @@ function onFocus() {
function onBlur() {
// Delay to allow click events on results
setTimeout(() => {
showResults.value = false
if (!isInteracting.value) {
close()
}
}, 200)
}
function onMouseLeave() {
isInteracting.value = false
setTimeout(() => {
if (!isInteracting.value) {
close()
}
}, 200)
}
function close() {
showResults.value = false
showDropdown.value = false
inputRef.value?.blur()
}
function clearQuery() {
query.value = ''
results.value = []
selectedIndex.value = -1
results.value = {}
selectedIndex.value = { category: null, index: -1 }
error.value = null
nextTick(() => {
inputRef.value?.focus()
})
}
// ── Keyboard navigation ─────────────────────────────────────────────
// ── Keyboard navigation ─────────────────────────────────────────────
function getAllFlattenedResults() {
const flat = []
for (const cat of activeCategoryChips.value) {
const catResults = results.value[cat.id] || []
for (let i = 0; i < catResults.length; i++) {
flat.push({ category: cat.id, index: i, ...catResults[i] })
}
}
return flat
}
function moveSelection(direction) {
if (results.value.length === 0) return
const flat = getAllFlattenedResults()
if (flat.length === 0) return
selectedIndex.value += direction
if (selectedIndex.value < 0) {
selectedIndex.value = results.value.length - 1
} else if (selectedIndex.value >= results.value.length) {
selectedIndex.value = 0
// Calculate current position
let currentIndex = 0
for (let i = 0; i < flat.length; i++) {
const catId = flat[i].category
const idx = flat[i].index
if (selectedCategory.value === catId && selectedIndex.value.index === idx) {
currentIndex = i
break
}
}
const nextIndex = (currentIndex + direction + flat.length) % flat.length
selectedIndex.value = { category: flat[nextIndex].category, index: flat[nextIndex].index }
}
function isItemSelected(catId, index) {
return selectedIndex.value.category === catId && selectedIndex.value.index === index
}
function selectCurrent() {
if (selectedIndex.value >= 0 && selectedIndex.value < results.value.length) {
navigateToMember(results.value[selectedIndex.value].id)
const flat = getAllFlattenedResults()
if (selectedIndex.value.category) {
const catResults = results.value[selectedIndex.value.category]
if (catResults && catResults[selectedIndex.value.index]) {
navigateToResult(catResults[selectedIndex.value.index])
}
}
}
// ── Navigation ──────────────────────────────────────────────────────
function navigateToMember(memberId) {
showResults.value = false
// ── Navigation ────────────────────────────────────────────────────────
function navigateToResult(result) {
showDropdown.value = false
query.value = ''
results.value = []
selectedIndex.value = -1
router.push({ name: 'member-detail', params: { id: memberId } })
}
results.value = {}
selectedIndex.value = { category: null, index: -1 }
// ── Formatting ──────────────────────────────────────────────────────
function formatStatus(status) {
const map = {
aktiv: 'Aktiv',
inaktiv: 'Inaktiv',
geloescht: 'Gelöscht',
if (previousView.value) {
// Already in a search context
} else {
// Set previous view for breadcrumb
previousView.value = {
name: router.currentRoute.value.name,
params: { ...router.currentRoute.value.params },
}
emit('update:previousView', previousView.value)
}
return map[status] || status
router.push(result.route)
}
function goBack() {
if (previousView.value) {
router.push(previousView.value)
previousView.value = null
emit('update:previousView', null)
}
}
// ── Watch for route changes (clear breadcrumb when navigating normally) ──
watch(() => router.currentRoute.value.name, () => {
// If the route changed but we didn't go through search, clear breadcrumb
if (!previousView.value) return
// Don't clear if the search bar is focused
})
// ── Watch for category change (invalidate in-flight request) ─────
watch(selectedCategory, () => {
inflightRequestId++
})
// ── Watch for previousView prop change ──
watch(() => props.previousView, (val) => {
previousView.value = val
})
defineExpose({
clear: () => {
clearQuery()
},
})
</script>
<style scoped>
.search-bar {
position: relative;
width: 280px;
min-width: 280px;
max-width: 600px;
width: 100%;
}
.search-bar__input-wrapper {
@@ -291,6 +492,56 @@ function formatStatus(status) {
color: var(--color-error);
}
/* Breadcrumb */
.search-bar__breadcrumb {
margin-top: 4px;
padding: 2px 0;
}
.search-bar__breadcrumb-link {
font-size: 0.85em;
color: var(--color-text-lighter);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius);
transition: background-color 0.15s ease;
}
.search-bar__breadcrumb-link:hover {
background-color: var(--color-background-hover);
}
/* Chips */
.search-bar__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px 4px;
}
.search-bar__chip {
background: var(--color-main-background);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-pill);
padding: 0 10px;
height: 28px;
font-size: 0.8em;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.search-bar__chip:hover {
background: var(--color-background-hover);
}
.search-bar__chip--active {
background: var(--color-primary-element);
color: white;
border-color: var(--color-primary-element);
}
/* Dropdown */
.search-bar__dropdown {
position: absolute;
@@ -302,7 +553,7 @@ function formatStatus(status) {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
max-height: 400px;
max-height: 50vh;
overflow-y: auto;
z-index: 1000;
}
@@ -352,6 +603,15 @@ function formatStatus(status) {
padding: 4px 0;
}
.search-bar__category-header {
padding: 6px 16px 4px;
font-size: 0.75em;
font-weight: 600;
color: var(--color-text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.search-bar__result {
padding: 8px 16px;
cursor: pointer;
@@ -363,51 +623,20 @@ function formatStatus(status) {
background: var(--color-background-hover);
}
.search-bar__result-name {
.search-bar__result-title {
font-weight: 500;
font-size: 0.95em;
}
.search-bar__result-context {
.search-bar__result-subtitle {
font-size: 0.85em;
color: var(--color-text-lighter);
font-size: 0.8em;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-bar__result-meta {
display: flex;
gap: 8px;
margin-top: 2px;
}
.search-bar__status {
display: inline-block;
padding: 1px 6px;
border-radius: var(--border-radius-pill);
font-size: 0.75em;
font-weight: 500;
}
.search-bar__status--aktiv {
background: var(--color-success);
color: white;
}
.search-bar__status--inaktiv {
background: var(--color-warning);
color: white;
}
.search-bar__status--geloescht {
background: var(--color-error);
color: white;
}
.search-bar__rolle {
font-size: 0.75em;
color: var(--color-text-lighter);
.search-bar__result-title mark {
background-color: var(--color-primary-element-light);
border-radius: var(--border-radius);
padding: 1px 3px;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="stock-item-form">
<form @submit.prevent="handleSubmit">
<div class="stock-item-form__field">
<label for="si-name">Name:</label>
<NcTextField
id="si-name"
:model-value="form.name"
:placeholder="'Name des Verkaufsmaterials'"
:validate="form.name !== ''"
@update:model-value="form.name = $event"
required
/>
</div>
<div class="stock-item-form__field">
<label for="si-categories">Kategorien:</label>
<NcSelect
id="si-categories"
:model-value="categoryOptions.filter(o => form.categories?.includes(o.value))"
:options="categoryOptions"
multiple
:reduce="o => o.value"
:close-on-select="false"
@update:model-value="form.categories = $event.map(o => o.value)"
/>
</div>
<div class="stock-item-form__field">
<label>Provider-URLs:</label>
<div v-for="(urlEntry, idx) in form.providerUrls" :key="idx" class="stock-item-form__url-row">
<NcTextField
:model-value="urlEntry.url"
:placeholder="'https://...'"
@update:model-value="form.providerUrls[idx].url = $event"
/>
<NcTextField
:model-value="urlEntry.provider"
:placeholder="'Provider'"
@update:model-value="form.providerUrls[idx].provider = $event"
/>
<NcButton type="tertiary-no-background" @click="removeUrl(idx)">
<template #icon>
<Close :size="20" />
</template>
</NcButton>
</div>
<NcButton type="tertiary" @click="addUrl">
<template #icon>
<Plus :size="20" />
</template>
URL hinzufügen
</NcButton>
</div>
<div class="stock-item-form__variants-header">
<span>Varianten:</span>
<NcButton type="tertiary" @click="addVariant">
<template #icon>
<Plus :size="20" />
</template>
Variante hinzufügen
</NcButton>
</div>
<div v-for="(variant, idx) in form.variants" :key="idx" class="stock-item-form__variant-row">
<NcTextField
:model-value="variant.label"
:placeholder="'Größe M, Farbe Blau...'"
:validate="variant.label !== ''"
@update:model-value="form.variants[idx].label = $event"
/>
<NcTextField
:type="'number'"
:model-value="variant.amount"
:validate="variant.amount >= 0"
@update:model-value="form.variants[idx].amount = Number($event) || 0"
/>
<NcTextField
:type="'text'"
:model-value="variant.cost"
:placeholder="'Stückpreis (€)'"
@update:model-value="variant.cost = $event"
/>
<NcTextField
:type="'number'"
:model-value="variant.min_threshold"
:validate="variant.min_threshold >= 0"
@update:model-value="form.variants[idx].min_threshold = Number($event) || 0"
/>
<NcButton type="tertiary-no-background" @click="removeVariant(idx)">
<template #icon>
<Close :size="20" />
</template>
</NcButton>
</div>
<div class="stock-item-form__actions">
<NcButton type="tertiary" @click="$emit('close')">
Abbrechen
</NcButton>
<NcButton type="primary" :disabled="!isFormValid" @click="handleSubmit">
{{ form.name ? (editMode ? 'Speichern' : 'Erstellen') : 'Speichern' }}
</NcButton>
</div>
</form>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { NcTextField, NcSelect, NcButton } from '@nextcloud/vue'
import Close from 'vue-material-design-icons/Close.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
const props = defineProps({
item: {
type: Object,
default: null,
},
categories: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['update', 'close'])
const editMode = computed(() => !!props.item)
const form = ref({
name: '',
categories: [],
providerUrls: [],
variants: [],
})
const categoryOptions = computed(() => {
return props.categories.map(c => ({
value: c.id,
label: c.name,
}))
})
const isFormValid = computed(() => form.value.name !== '')
// Load existing item data
watch(() => props.item, (newVal) => {
if (newVal) {
let urls = []
try {
urls = JSON.parse(newVal.provider_urls_json || '[]')
} catch {
urls = []
}
form.value = {
name: newVal.name || '',
categories: newVal.categories || [],
providerUrls: urls.map(u => ({ url: u.url || '', provider: u.provider || '' })),
variants: (newVal.variants || []).map(v => ({
id: v.id,
label: v.label || '',
amount: v.amount || 0,
cost: v.cost || '0.00',
min_threshold: v.min_threshold || 0,
})),
}
}
}, { immediate: true })
function addUrl() {
form.value.providerUrls.push({ url: '', provider: '' })
}
function removeUrl(idx) {
form.value.providerUrls.splice(idx, 1)
}
function addVariant() {
form.value.variants.push({
label: '',
amount: 0,
cost: '0.00',
min_threshold: 0,
})
}
function removeVariant(idx) {
form.value.variants.splice(idx, 1)
}
async function handleSubmit() {
if (!isFormValid.value) return
const data = {
name: form.value.name,
categories: form.value.categories,
provider_urls_json: JSON.stringify(form.value.providerUrls),
variants: form.value.variants.map(v => ({
label: v.label,
amount: v.amount || 0,
cost: v.cost || '0.00',
min_threshold: v.min_threshold || 0,
id: v.id,
})),
}
if (editMode.value) {
await emit('update', 'stock', 'update', { id: props.item.id, ...data })
} else {
await emit('update', 'stock', 'create', data)
}
}
</script>
<style scoped>
.stock-item-form {
max-width: 600px;
}
.stock-item-form__field {
margin-bottom: 16px;
}
.stock-item-form__field label {
display: block;
margin-bottom: 4px;
font-weight: 600;
font-size: 14px;
}
.stock-item-form__url-row,
.stock-item-form__variant-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
.stock-item-form__variants-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 16px 0 8px;
}
.stock-item-form__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="stock-variant-list">
<div v-if="variants.length > 0" class="stock-variant-list__variants">
<StockVariantRow
v-for="variant in variants"
:key="variant.id"
:variant="variant"
:stock-item-id="stockItemId"
@update="$emit('update-variant', $event)"
@delete="$emit('delete-variant', $event)"
/>
</div>
<div v-else class="stock-variant-list__empty">
Keine Varianten vorhanden
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import StockVariantRow from './StockVariantRow.vue'
defineProps({
variants: {
type: Array,
default: () => [],
},
stockItemId: {
type: Number,
required: true,
},
})
defineEmits(['update-variant', 'delete-variant'])
</script>
<style scoped>
.stock-variant-list__variants {
margin-bottom: 8px;
}
.stock-variant-list__empty {
color: var(--color-text-maxcontrast);
font-style: italic;
padding: 8px;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<template>
<div class="stock-variant-row">
<NcTextField
:model-value="variant.label"
:placeholder="'Größe M, Farbe Blau...'"
:validate="variant.label !== ''"
@update:model-value="$emit('update-variant', { ...variant, label: $event })"
/>
<NcTextField
:type="'number'"
:model-value="variant.amount"
:validate="variant.amount >= 0"
@update:model-value="$emit('update-variant', { ...variant, amount: Number($event) || 0 })"
/>
<NcTextField
:type="'text'"
:model-value="variant.cost"
:placeholder="'Stückpreis (€)'"
@update:model-value="$emit('update-variant', { ...variant, cost: $event })"
/>
<NcTextField
:type="'number'"
:model-value="variant.min_threshold"
:validate="variant.min_threshold >= 0"
@update:model-value="$emit('update-variant', { ...variant, min_threshold: Number($event) || 0 })"
/>
<NcButton type="tertiary-no-background" @click="$emit('delete-variant', variant.id)">
<template #icon>
<Close :size="20" />
</template>
</NcButton>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { NcTextField, NcButton } from '@nextcloud/vue'
import Close from 'vue-material-design-icons/Close.vue'
defineProps({
variant: {
type: Object,
required: true,
},
stockItemId: {
type: Number,
required: true,
},
})
defineEmits(['update-variant', 'delete-variant'])
</script>
<style scoped>
.stock-variant-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
</style>
+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.4.0')
app.mount('#mitgliederverwaltung')
+5
View File
@@ -78,6 +78,11 @@ const routes = [
name: 'backup',
component: () => import('./views/Backup.vue'),
},
{
path: '/inventory',
name: 'inventory',
component: () => import('./views/Inventory.vue'),
},
]
const router = createRouter({
+86
View File
@@ -0,0 +1,86 @@
/**
* Pinia store for inventory categories.
*
* Handles fetching and CRUD operations for inventory categories.
*
* Part of Issue #165 (Inventory Tracking).
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useCategoriesStore = defineStore('inventoryCategories', {
state: () => ({
/** @type {Array} List of categories */
categories: [],
/** @type {boolean} Loading state */
loading: false,
/** @type {string|null} Error message */
error: null,
}),
actions: {
/**
* Fetch all categories.
*/
async fetchCategories() {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/categories')
const response = await axios.get(url)
this.categories = response.data.data || []
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Kategorien'
console.error('Failed to fetch inventory categories:', err)
} finally {
this.loading = false
}
},
/**
* Create a new category.
*/
async createCategory(name) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/categories')
const response = await axios.post(url, { name })
this.categories.push(response.data)
this.categories.sort((a, b) => a.name.localeCompare(b.name))
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen der Kategorie'
throw err
} finally {
this.loading = false
}
},
/**
* Delete a category.
*/
async deleteCategory(id) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/categories/${id}`)
await axios.delete(url)
this.categories = this.categories.filter(c => c.id !== id)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Löschen der Kategorie'
throw err
} finally {
this.loading = false
}
},
clearError() {
this.error = null
},
},
})
-23
View File
@@ -83,29 +83,6 @@ export const useFamiliesStore = defineStore('families', {
}
},
/**
* Search families by name.
*/
async searchFamilies(query) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/families')
const response = await axios.get(url, {
params: { search: query },
})
this.families = response.data.data
this.total = response.data.total
} catch (err) {
this.error = err.response?.data?.error || 'Fehler bei der Suche'
console.error('Failed to search families:', err)
} finally {
this.loading = false
}
},
/**
* Create a new family.
*/
+112
View File
@@ -0,0 +1,112 @@
/**
* Pinia store for general material inventory.
*
* Handles fetching and CRUD operations for general material items.
*
* Part of Issue #165 (Inventory Tracking).
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useGeneralStore = defineStore('inventoryGeneral', {
state: () => ({
/** @type {Array} List of general material items */
items: [],
/** @type {boolean} Loading state */
loading: false,
/** @type {string|null} Error message */
error: null,
}),
getters: {
},
actions: {
/**
* Fetch all general material items.
*/
async fetchItems() {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/general')
const response = await axios.get(url)
this.items = response.data.data || []
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Allgemeinmaterialien'
console.error('Failed to fetch general material:', err)
} finally {
this.loading = false
}
},
/**
* Create a new general material item.
*/
async createItem(data) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/general')
const response = await axios.post(url, data)
this.items.push(response.data)
this.items.sort((a, b) => a.name.localeCompare(b.name))
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen des Materials'
throw err
} finally {
this.loading = false
}
},
/**
* Update an existing general material item.
*/
async updateItem(id, data) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/general/${id}`)
const response = await axios.put(url, data)
const index = this.items.findIndex(i => i.id === id)
if (index !== -1) {
this.items[index] = response.data
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Aktualisieren des Materials'
throw err
} finally {
this.loading = false
}
},
/**
* Delete a general material item.
*/
async deleteItem(id) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/general/${id}`)
await axios.delete(url)
this.items = this.items.filter(i => i.id !== id)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Löschen des Materials'
throw err
} finally {
this.loading = false
}
},
clearError() {
this.error = null
},
},
})
+73
View File
@@ -0,0 +1,73 @@
/**
* Pinia store for inventory report data.
*
* Handles fetching condition and sales report data.
*
* Part of Issue #165 (Inventory Tracking).
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useInventoryReportsStore = defineStore('inventoryReports', {
state: () => ({
/** @type {object|null} Condition report data */
conditionReport: null,
/** @type {object|null} Sales report data */
salesReport: null,
/** @type {boolean} Loading state for condition report */
conditionLoading: false,
/** @type {boolean} Loading state for sales report */
salesLoading: false,
/** @type {string|null} Error message */
error: null,
}),
actions: {
/**
* Fetch condition report data.
*/
async fetchConditionReport() {
this.conditionLoading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/reports/condition')
const response = await axios.get(url)
this.conditionReport = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden des Materialzustandsberichts'
console.error('Failed to fetch condition report:', err)
} finally {
this.conditionLoading = false
}
},
/**
* Fetch sales report data with optional date range.
*/
async fetchSalesReport(dateFrom, dateTo) {
this.salesLoading = true
this.error = null
try {
const params = {}
if (dateFrom) params.dateFrom = dateFrom
if (dateTo) params.dateTo = dateTo
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/reports/sales')
const response = await axios.get(url, { params })
this.salesReport = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden des Inventur-Verkaufsberichts'
console.error('Failed to fetch sales report:', err)
} finally {
this.salesLoading = false
}
},
clearError() {
this.error = null
},
},
})
-23
View File
@@ -137,29 +137,6 @@ export const useMembersStore = defineStore('members', {
}
},
/**
* Search members by name.
*/
async searchMembers(query) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members')
const response = await axios.get(url, {
params: { search: query },
})
this.members = response.data.data
this.total = response.data.total
} catch (err) {
this.error = err.response?.data?.error || 'Fehler bei der Suche'
console.error('Failed to search members:', err)
} finally {
this.loading = false
}
},
/**
* Create a new member.
*/
+122
View File
@@ -0,0 +1,122 @@
/**
* Pinia store for inventory sales records.
*
* Handles fetching, creating, and filtering sales records.
*
* Part of Issue #165 (Inventory Tracking).
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useSalesStore = defineStore('inventorySales', {
state: () => ({
/** @type {Array} List of sales records */
sales: [],
/** @type {string|null} Filter from date */
filterDateFrom: null,
/** @type {string|null} Filter to date */
filterDateTo: null,
/** @type {boolean} Loading state */
loading: false,
/** @type {string|null} Error message */
error: null,
}),
getters: {
/**
* Filtered sales based on date range.
*/
filteredSales: (state) => {
if (!state.filterDateFrom && !state.filterDateTo) {
return state.sales
}
return state.sales.filter(sale => {
const saleDate = sale.date
if (state.filterDateFrom && saleDate < state.filterDateFrom) {
return false
}
if (state.filterDateTo && saleDate > state.filterDateTo) {
return false
}
return true
})
},
},
actions: {
/**
* Fetch sales records with optional date filter.
*/
async fetchSales() {
this.loading = true
this.error = null
try {
const params = {}
if (this.filterDateFrom) params.dateFrom = this.filterDateFrom
if (this.filterDateTo) params.dateTo = this.filterDateTo
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/sales')
const response = await axios.get(url, { params })
this.sales = response.data.data || []
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Verkäufe'
console.error('Failed to fetch sales:', err)
} finally {
this.loading = false
}
},
/**
* Create a new sale record.
*/
async createSale(data) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/sales')
const response = await axios.post(url, data)
this.sales.unshift(response.data)
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen des Verkaufs'
throw err
} finally {
this.loading = false
}
},
/**
* Delete a sale record.
*/
async deleteSale(id) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/sales/${id}`)
await axios.delete(url)
this.sales = this.sales.filter(s => s.id !== id)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Löschen des Verkaufs'
throw err
} finally {
this.loading = false
}
},
setFilterDateFrom(date) {
this.filterDateFrom = date
},
setFilterDateTo(date) {
this.filterDateTo = date
},
clearError() {
this.error = null
},
},
})
+165
View File
@@ -0,0 +1,165 @@
/**
* Pinia store for stock (sales) items.
*
* Handles fetching and CRUD operations for stock items with variants.
*
* Part of Issue #165 (Inventory Tracking).
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useStockStore = defineStore('inventoryStock', {
state: () => ({
/** @type {Array} List of stock items */
items: [],
/** @type {boolean} Loading state */
loading: false,
/** @type {string|null} Error message */
error: null,
}),
getters: {
},
actions: {
/**
* Fetch all stock items with their variants.
*/
async fetchItems() {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/stock')
const response = await axios.get(url)
this.items = response.data.data || []
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden des Verkaufsmaterials'
console.error('Failed to fetch stock items:', err)
} finally {
this.loading = false
}
},
/**
* Create a new stock item with variants.
*/
async createItem(data) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/stock')
const response = await axios.post(url, data)
this.items.push(response.data)
this.items.sort((a, b) => a.name.localeCompare(b.name))
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen des Verkaufsmaterials'
throw err
} finally {
this.loading = false
}
},
/**
* Update an existing stock item.
*/
async updateItem(id, data) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/stock/${id}`)
const response = await axios.put(url, data)
const index = this.items.findIndex(i => i.id === id)
if (index !== -1) {
this.items[index] = response.data
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Aktualisieren des Verkaufsmaterials'
throw err
} finally {
this.loading = false
}
},
/**
* Delete a stock item.
*/
async deleteItem(id) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/stock/${id}`)
await axios.delete(url)
this.items = this.items.filter(i => i.id !== id)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Löschen des Verkaufsmaterials'
throw err
} finally {
this.loading = false
}
},
/**
* Add a variant to an item.
*/
async addVariant(stockItemId, variantData) {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/inventory/variants')
const response = await axios.post(url, {
stock_item_id: stockItemId,
...variantData,
})
const item = this.items.find(i => i.id === stockItemId)
if (item) {
if (!item.variants) item.variants = []
item.variants.push(response.data)
item.total_amount = (item.total_amount || 0) + (response.data.amount || 0)
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen der Variante'
throw err
} finally {
this.loading = false
}
},
/**
* Delete a variant.
*/
async deleteVariant(variantId, stockItemId) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/inventory/variants/${variantId}`)
await axios.delete(url)
const item = this.items.find(i => i.id === stockItemId)
if (item && item.variants) {
item.variants = item.variants.filter(v => v.id !== variantId)
}
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Löschen der Variante'
throw err
} finally {
this.loading = false
}
},
clearError() {
this.error = null
},
},
})
+2 -27
View File
@@ -3,12 +3,6 @@
<div class="family-list__header">
<h2>Familien</h2>
<div class="family-list__actions">
<NcTextField :model-value="searchQuery"
:placeholder="'Familien suchen...'"
:show-trailing-button="searchQuery !== ''"
trailing-button-icon="close"
@trailing-button-click="clearSearch"
@update:model-value="onSearch" />
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
@@ -45,7 +39,7 @@
<!-- Empty state -->
<NcEmptyContent v-else-if="!store.loading && store.families.length === 0"
name="Keine Familien"
:description="searchQuery ? 'Keine Familien für diese Suche gefunden.' : 'Noch keine Familien angelegt.'">
:description="'Noch keine Familien angelegt.'">
<template #icon>
<AccountMultiple :size="64" />
</template>
@@ -100,7 +94,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useFamiliesStore } from '../stores/families.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import { usePageSize } from '../utils/usePageSize.js'
@@ -120,10 +114,8 @@ function onPageSizeChange(size) {
store.setLimit(size)
}
const searchQuery = ref('')
const sortField = ref('name')
const sortAsc = ref(true)
let searchTimeout = null
function getAnsprechpartner(family) {
if (!family.members || family.members.length === 0) return '\u2014'
@@ -190,23 +182,6 @@ function toggleSort(field) {
}
}
function onSearch(value) {
searchQuery.value = value
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
if (value.trim()) {
store.searchFamilies(value.trim())
} else {
store.fetchFamilies()
}
}, 300)
}
function clearSearch() {
searchQuery.value = ''
store.fetchFamilies()
}
function reload() {
store.clearError()
store.fetchFamilies()
+511
View File
@@ -0,0 +1,511 @@
<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>
<!-- 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.items.length === 0 && !generalStore.error" class="inventory__empty">
<Inbox :size="48" />
<p>Keine Allgemeinmaterialien vorhanden</p>
</div>
<!-- Table -->
<div v-if="!generalStore.loading && generalStore.items.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.items" :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>
<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.items.length === 0 && !stockStore.error" class="inventory__empty">
<Inbox :size="48" />
<p>Kein Verkaufsmaterial vorhanden</p>
</div>
<div v-if="!stockStore.loading && stockStore.items.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.items" :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, 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 { 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__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>
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class GeneralMaterialMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('andWhere')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('gte')->willReturn('col >= :param');
$this->qb->method('lte')->willReturn('col <= :param');
$this->qb->method('isNotNull')->willReturn('col IS NOT NULL');
$this->qb->method('insert')->willReturnSelf();
$this->qb->method('delete')->willReturnSelf();
$this->qb->method('values')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->expr->method('neq')->willReturn('col != :param');
$this->expr->method('orX')->willReturn('or_expr');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultSingleRow(array $row): void {
$this->result->method('fetch')
->willReturnOnConsecutiveCalls($row, false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultEmpty(): void {
$this->result->method('fetch')->willReturn(false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function itemRow(int $id = 1, string $name = 'Zelt A', ?int $condition = 4, ?string $notes = 'Sehr gut'): array {
return [
'id' => $id,
'name' => $name,
'condition' => $condition,
'notes' => $notes,
'created_at' => '2026-04-21 00:00:00',
'updated_at' => '2026-04-21 00:00:00',
];
}
public function testCreateAndFind(): void {
$this->configureResultSingleRow($this->itemRow(42, 'Zelt A', 4, 'Sehr gut'));
$mapper = new GeneralMaterialMapper($this->db);
$item = $mapper->findById(42);
$this->assertSame(42, $item->getId());
$this->assertSame('Zelt A', $item->getName());
$this->assertSame(4, $item->getCondition());
}
public function testFindAll(): void {
$this->configureResultRows([
$this->itemRow(1, 'Zelt A', 4),
$this->itemRow(2, 'Zelt B', 3),
]);
$mapper = new GeneralMaterialMapper($this->db);
$items = $mapper->findAll();
$this->assertCount(2, $items);
$this->assertSame('Zelt A', $items[0]->getName());
}
public function testFindByConditionRange(): void {
$this->configureResultRows([$this->itemRow(1, 'Zelt A', 1), $this->itemRow(2, 'Zelt B', 2)]);
$mapper = new GeneralMaterialMapper($this->db);
$items = $mapper->findByConditionRange(0, 2);
$this->assertCount(2, $items);
$this->assertLessThanOrEqual(2, $items[0]->getCondition());
}
public function testFindByConditionRangeEmpty(): void {
$this->configureResultRows([]);
$mapper = new GeneralMaterialMapper($this->db);
$items = $mapper->findByConditionRange(4, 5);
$this->assertCount(0, $items);
}
public function testUpdateCondition(): void {
$this->configureResultSingleRow($this->itemRow(1, 'Zelt A', 3, 'Gut'));
$mapper = new GeneralMaterialMapper($this->db);
$item = $mapper->findById(1);
$this->assertSame(3, $item->getCondition());
$this->assertSame('Gut', $item->getNotes());
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class InventoryCategoryMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('andWhere')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('insert')->willReturnSelf();
$this->qb->method('delete')->willReturnSelf();
$this->qb->method('values')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->expr->method('neq')->willReturn('col != :param');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultSingleRow(array $row): void {
$this->result->method('fetch')
->willReturnOnConsecutiveCalls($row, false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultEmpty(): void {
$this->result->method('fetch')->willReturn(false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function categoryRow(int $id = 1, string $name = 'Zelte'): array {
return [
'id' => $id,
'name' => $name,
'created_at' => '2026-04-21 00:00:00',
];
}
public function testCreateAndFindById(): void {
$this->configureResultSingleRow($this->categoryRow(42, 'Zelte'));
$mapper = new InventoryCategoryMapper($this->db);
$category = $mapper->findById(42);
$this->assertSame(42, $category->getId());
$this->assertSame('Zelte', $category->getName());
}
public function testFindByName(): void {
$this->configureResultSingleRow($this->categoryRow(1, 'Kochgeschirr'));
$mapper = new InventoryCategoryMapper($this->db);
$category = $mapper->findByName('Kochgeschirr');
$this->assertSame('Kochgeschirr', $category->getName());
}
public function testFindByNameThrowsWhenNotFound(): void {
$this->configureResultEmpty();
$mapper = new InventoryCategoryMapper($this->db);
$this->expectException(DoesNotExistException::class);
$mapper->findByName('NichtExistent');
}
public function testFindAll(): void {
$this->configureResultRows([
$this->categoryRow(1, 'Zelte'),
$this->categoryRow(2, 'Kochgeschirr'),
$this->categoryRow(3, 'Seile'),
]);
$mapper = new InventoryCategoryMapper($this->db);
$categories = $mapper->findAll();
$this->assertCount(3, $categories);
$this->assertSame('Zelte', $categories[0]->getName());
}
public function testFindAllEmpty(): void {
$this->configureResultRows([]);
$mapper = new InventoryCategoryMapper($this->db);
$categories = $mapper->findAll();
$this->assertCount(0, $categories);
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class InventoryItemCategoryMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('andWhere')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('insert')->willReturnSelf();
$this->qb->method('delete')->willReturnSelf();
$this->qb->method('values')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->qb->method('executeStatement')->willReturn(1);
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function row(int $itemId = 1, int $categoryId = 1): array {
return [
'item_id' => $itemId,
'category_id' => $categoryId,
];
}
public function testAttach_detach(): void {
$mapper = new InventoryItemCategoryMapper($this->db);
$mapper->attach(1, 1);
$this->assertTrue(true); // No exception = success
}
public function testDetach(): void {
$mapper = new InventoryItemCategoryMapper($this->db);
$mapper->detach(1, 1);
$this->assertTrue(true);
}
public function testFindByItemIdReturnsCategories(): void {
$this->configureResultRows([
$this->row(1, 1),
$this->row(1, 2),
]);
$mapper = new InventoryItemCategoryMapper($this->db);
$cats = $mapper->findByItemId(1);
$this->assertCount(2, $cats);
$this->assertSame(1, $cats[0]->getCategoryId());
$this->assertSame(2, $cats[1]->getCategoryId());
}
public function testFindByItemIdEmpty(): void {
$this->configureResultRows([]);
$mapper = new InventoryItemCategoryMapper($this->db);
$cats = $mapper->findByItemId(999);
$this->assertCount(0, $cats);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\SaleRecord;
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SaleRecordMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('selectDistinct')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('andWhere')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('insert')->willReturnSelf();
$this->qb->method('delete')->willReturnSelf();
$this->qb->method('values')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('gte')->willReturn('col >= :param');
$this->expr->method('lte')->willReturn('col <= :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->expr->method('neq')->willReturn('col != :param');
$this->expr->method('orX')->willReturn('or_expr');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultSingleRow(array $row): void {
$this->result->method('fetch')
->willReturnOnConsecutiveCalls($row, false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultEmpty(): void {
$this->result->method('fetch')->willReturn(false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultCount(int $count): void {
$this->result->method('fetchOne')->willReturn((string)$count);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function saleRow(int $id = 1, ?int $stockItemId = 1, ?int $variantId = 1, string $date = '2026-04-21', int $quantity = 2, string $unitPrice = '25.00', string $totalPrice = '50.00'): array {
return [
'id' => $id,
'stock_item_id' => $stockItemId,
'variant_id' => $variantId,
'date' => $date,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'total_price' => $totalPrice,
'notes' => null,
'created_at' => '2026-04-21 00:00:00',
];
}
public function testCreateAndFind(): void {
$this->configureResultSingleRow($this->saleRow(42, 1, 1, '2026-04-21', 2, '25.00', '50.00'));
$mapper = new SaleRecordMapper($this->db);
$sale = $mapper->findById(42);
$this->assertSame(42, $sale->getId());
$this->assertSame(2, $sale->getQuantity());
$this->assertSame('50.00', $sale->getTotalPrice());
}
public function testFindByDateRange(): void {
$this->configureResultRows([
$this->saleRow(1, 1, null, '2026-03-15', 1, '30.00', '30.00'),
$this->saleRow(2, 1, 2, '2026-04-01', 2, '25.00', '50.00'),
]);
$mapper = new SaleRecordMapper($this->db);
$sales = $mapper->findByDateRange('2026-03-01', '2026-04-30');
$this->assertCount(2, $sales);
$this->assertSame('2026-04-01', $sales[0]->getDate());
}
public function testFindByDateRangeEmpty(): void {
$this->configureResultRows([]);
$mapper = new SaleRecordMapper($this->db);
$sales = $mapper->findByDateRange('2000-01-01', '2000-12-31');
$this->assertCount(0, $sales);
}
public function testTotalPriceCalculation(): void {
$this->configureResultSingleRow($this->saleRow(1, 1, 1, '2026-04-01', 3, '15.00', '45.00'));
$mapper = new SaleRecordMapper($this->db);
$sale = $mapper->findById(1);
$this->assertSame(3, $sale->getQuantity());
$this->assertSame('15.00', $sale->getUnitPrice());
$this->assertSame('45.00', $sale->getTotalPrice());
// Verify: 3 × 15.00 = 45.00
$this->assertSame((float)$sale->getTotalPrice(), (float)$sale->getQuantity() * (float)$sale->getUnitPrice());
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\StockItem;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class StockItemMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultSingleRow(array $row): void {
$this->result->method('fetch')
->willReturnOnConsecutiveCalls($row, false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultEmpty(): void {
$this->result->method('fetch')->willReturn(false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function stockRow(int $id = 1, string $name = 'Pfadfinderhemd', ?string $urls = '[{"url":"https://shop.example.com","provider":"shop"}]'): array {
return [
'id' => $id,
'name' => $name,
'provider_urls_json' => $urls,
'created_at' => '2026-04-21 00:00:00',
'updated_at' => '2026-04-21 00:00:00',
];
}
public function testCreateAndFind(): void {
$this->configureResultSingleRow($this->stockRow(42, 'Pfadfinderhemd'));
$mapper = new StockItemMapper($this->db);
$item = $mapper->findById(42);
$this->assertSame(42, $item->getId());
$this->assertSame('Pfadfinderhemd', $item->getName());
}
public function testFindAll(): void {
$this->configureResultRows([
$this->stockRow(1, 'Pfadfinderhemd'),
$this->stockRow(2, 'Pfadfindershirt'),
]);
$mapper = new StockItemMapper($this->db);
$items = $mapper->findAll();
$this->assertCount(2, $items);
$this->assertSame('Pfadfinderhemd', $items[0]->getName());
}
public function testProviderUrlsJsonSerialization(): void {
$this->configureResultSingleRow($this->stockRow(1, 'Hemd', '[{"url":"https://shop.example.com","provider":"shop"},{"url":"https://bestell.example.com","provider":"bestell"}]'));
$mapper = new StockItemMapper($this->db);
$item = $mapper->findById(1);
$json = $item->getProviderUrlsJson();
$this->assertNotNull($json);
$decoded = json_decode($json, true);
$this->assertCount(2, $decoded);
$this->assertSame('https://shop.example.com', $decoded[0]['url']);
$this->assertSame('shop', $decoded[0]['provider']);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Db;
use OCA\Mitgliederverwaltung\Db\StockVariant;
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IExpressionBuilder;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\IResult;
use OCP\IDBConnection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class StockVariantMapperTest extends TestCase {
private IDBConnection&MockObject $db;
private IQueryBuilder&MockObject $qb;
private IExpressionBuilder&MockObject $expr;
private IResult&MockObject $result;
protected function setUp(): void {
parent::setUp();
$this->db = $this->createMock(IDBConnection::class);
$this->qb = $this->createMock(IQueryBuilder::class);
$this->expr = $this->createMock(IExpressionBuilder::class);
$this->result = $this->createMock(IResult::class);
$this->qb->method('expr')->willReturn($this->expr);
$this->qb->method('select')->willReturnSelf();
$this->qb->method('from')->willReturnSelf();
$this->qb->method('where')->willReturnSelf();
$this->qb->method('orderBy')->willReturnSelf();
$this->qb->method('createNamedParameter')->willReturn(':param');
$this->qb->method('createFunction')->willReturnCallback(fn($call) => $call);
$this->qb->method('getSQL')->willReturn('SELECT * FROM test');
$this->expr->method('eq')->willReturn('col = :param');
$this->expr->method('isNull')->willReturn('col IS NULL');
$this->db->method('getQueryBuilder')->willReturn($this->qb);
}
private function configureResultRows(array $rows): void {
$fetchCalls = array_merge($rows, [false]);
$this->result->method('fetch')
->willReturnOnConsecutiveCalls(...$fetchCalls);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultSingleRow(array $row): void {
$this->result->method('fetch')
->willReturnOnConsecutiveCalls($row, false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function configureResultEmpty(): void {
$this->result->method('fetch')->willReturn(false);
$this->result->method('closeCursor')->willReturn(true);
$this->qb->method('executeQuery')->willReturn($this->result);
}
private function variantRow(int $id = 1, int $stockItemId = 1, string $label = 'Größe M', int $amount = 10, string $cost = '25.00', int $minThreshold = 3): array {
return [
'id' => $id,
'stock_item_id' => $stockItemId,
'label' => $label,
'amount' => $amount,
'cost' => $cost,
'min_threshold' => $minThreshold,
];
}
public function testCreate_attachToStockItem(): void {
$this->configureResultSingleRow($this->variantRow(1, 1, 'Größe M'));
$mapper = new StockVariantMapper($this->db);
$variant = $mapper->findById(1);
$this->assertSame(1, $variant->getId());
$this->assertSame(1, $variant->getStockItemId());
$this->assertSame('Größe M', $variant->getLabel());
}
public function testFindByStockItemId(): void {
$this->configureResultRows([
$this->variantRow(1, 1, 'Größe M'),
$this->variantRow(2, 1, 'Größe L'),
]);
$mapper = new StockVariantMapper($this->db);
$variants = $mapper->findByStockItemId(1);
$this->assertCount(2, $variants);
$this->assertSame('Größe M', $variants[0]->getLabel());
$this->assertSame('Größe L', $variants[1]->getLabel());
}
public function testFindByStockItemIdEmpty(): void {
$this->configureResultRows([]);
$mapper = new StockVariantMapper($this->db);
$variants = $mapper->findByStockItemId(999);
$this->assertCount(0, $variants);
}
}
@@ -6,6 +6,7 @@ namespace OCA\Mitgliederverwaltung\Tests\Integration;
use OCA\Mitgliederverwaltung\Controller\ReportController;
use OCA\Mitgliederverwaltung\Service\EncryptedExportService;
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
use OCA\Mitgliederverwaltung\Service\PdfService;
use OCA\Mitgliederverwaltung\Service\PermissionService;
use OCA\Mitgliederverwaltung\Service\ReportService;
@@ -25,6 +26,7 @@ class ReportControllerTest extends TestCase {
private ReportController $controller;
private ReportService&MockObject $reportService;
private InventoryReportService&MockObject $inventoryReportService;
private PdfService&MockObject $pdfService;
private EncryptedExportService&MockObject $encryptedService;
private PermissionService&MockObject $permissionService;
@@ -36,6 +38,7 @@ class ReportControllerTest extends TestCase {
parent::setUp();
$this->reportService = $this->createMock(ReportService::class);
$this->inventoryReportService = $this->createMock(InventoryReportService::class);
$this->pdfService = $this->createMock(PdfService::class);
$this->encryptedService = $this->createMock(EncryptedExportService::class);
$this->permissionService = $this->createMock(PermissionService::class);
@@ -52,6 +55,7 @@ class ReportControllerTest extends TestCase {
'mitgliederverwaltung',
$this->request,
$this->reportService,
$this->inventoryReportService,
$this->pdfService,
$this->encryptedService,
$this->permissionService,
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Service;
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCA\Mitgliederverwaltung\Db\StockItem;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCA\Mitgliederverwaltung\Db\StockVariant;
use OCA\Mitgliederverwaltung\Db\StockVariantMapper;
use OCA\Mitgliederverwaltung\Db\SaleRecord;
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
use OCA\Mitgliederverwaltung\Service\InventoryReportService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class InventoryReportServiceTest extends TestCase {
private InventoryReportService $service;
private MockObject $generalMaterialMapper;
private MockObject $categoryMapper;
private MockObject $itemCategoryMapper;
private MockObject $stockItemMapper;
private MockObject $stockVariantMapper;
private MockObject $saleRecordMapper;
private LoggerInterface&MockObject $logger;
protected function setUp(): void {
parent::setUp();
$this->generalMaterialMapper = $this->createMock(GeneralMaterialMapper::class);
$this->categoryMapper = $this->createMock(InventoryCategoryMapper::class);
$this->itemCategoryMapper = $this->createMock(InventoryItemCategoryMapper::class);
$this->stockItemMapper = $this->createMock(StockItemMapper::class);
$this->stockVariantMapper = $this->createMock(StockVariantMapper::class);
$this->saleRecordMapper = $this->createMock(SaleRecordMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new InventoryReportService(
$this->generalMaterialMapper,
$this->categoryMapper,
$this->itemCategoryMapper,
$this->stockItemMapper,
$this->stockVariantMapper,
$this->saleRecordMapper,
$this->logger
);
}
private function generalMaterialEntity(int $id = 1, string $name = 'Zelt A', ?int $condition = 1): GeneralMaterial {
$entity = new GeneralMaterial();
$entity->setId($id);
$entity->setName($name);
$entity->setCondition($condition);
$entity->setNotes(null);
return $entity;
}
private function categoryEntity(int $id = 1, string $name = 'Zelte'): InventoryCategory {
$entity = new InventoryCategory();
$entity->setId($id);
$entity->setName($name);
return $entity;
}
private function itemCategoryEntity(int $itemId = 1, int $categoryId = 1): InventoryItemCategory {
$entity = new InventoryItemCategory();
$entity->setItemId($itemId);
$entity->setCategoryId($categoryId);
return $entity;
}
private function saleEntity(int $id = 1, int $stockItemId = 1, string $quantity = '2', string $unitPrice = '25.00'): SaleRecord {
$entity = new SaleRecord();
$entity->setId($id);
$entity->setStockItemId($stockItemId);
$entity->setVariantId(null);
$entity->setDate('2026-04-01');
$entity->setQuantity((int)$quantity);
$entity->setUnitPrice($unitPrice);
$entity->setTotalPrice((string)((float)$quantity * (float)$unitPrice));
$entity->setNotes(null);
$entity->setCreatedAt('2026-04-01 00:00:00');
return $entity;
}
public function testGenerateSalesReport_dateFiltered(): void {
$sales = [
$this->saleEntity(1, 1, '2', '25.00'),
$this->saleEntity(2, 1, '1', '30.00'),
];
$this->saleRecordMapper->method('findByDateRange')
->with('2026-01-01', '2026-03-31')
->willReturn($sales);
$report = $this->service->generateSalesReport('2026-01-01', '2026-03-31');
$this->assertStringContainsString('2026-01-01', $report['title']);
$this->assertCount(2, $report['rows']);
$this->assertArrayHasKey('total_sales', $report['summary']);
$this->assertSame(2, (int)$report['summary']['total_sales']);
}
public function testGenerateConditionReport_summaryByCategory(): void {
$item1 = $this->generalMaterialEntity(1, 'Zelt A', 3);
$item2 = $this->generalMaterialEntity(2, 'Zelt B', 4);
$this->generalMaterialMapper->method('findAll')
->willReturn([$item1, $item2]);
$itemCat1 = $this->itemCategoryEntity(1, 1);
$this->itemCategoryMapper->method('findByItemId')
->willReturnCallback(function($itemId) use ($itemCat1) {
if ($itemId === 1) {
return [$itemCat1];
}
return [];
});
$catEntity = $this->categoryEntity(1, 'Zelte');
$this->categoryMapper->method('findById')
->willReturn($catEntity);
// Also need findNeedingRepair to return empty
$this->generalMaterialMapper->method('findNeedingRepair')
->willReturn([]);
$report = $this->service->generateConditionReport();
$this->assertArrayHasKey('Zelte', $report['summary']);
$this->assertSame(1, $report['summary']['Zelte']);
}
public function testGenerateConditionReport_needsRepairOnly(): void {
// One good item, one needing repair
$goodItem = $this->generalMaterialEntity(1, 'Zelt A', 4);
$badItem = $this->generalMaterialEntity(2, 'Zelt B', 1);
$this->generalMaterialMapper->method('findAll')
->willReturn([$goodItem, $badItem]);
$this->itemCategoryMapper->method('findByItemId')
->willReturn([]);
$this->generalMaterialMapper->method('findNeedingRepair')
->willReturn([$badItem]);
$report = $this->service->generateConditionReport();
// Only the bad item should be in rows
$this->assertCount(1, $report['rows']);
$this->assertSame('Zelt B', $report['rows'][0]['name']);
$this->assertSame('1/5', $report['rows'][0]['condition']);
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Service;
use OCA\Mitgliederverwaltung\Db\GeneralMaterial;
use OCA\Mitgliederverwaltung\Db\GeneralMaterialMapper;
use OCA\Mitgliederverwaltung\Db\InventoryCategory;
use OCA\Mitgliederverwaltung\Db\InventoryCategoryMapper;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategory;
use OCA\Mitgliederverwaltung\Db\InventoryItemCategoryMapper;
use OCA\Mitgliederverwaltung\Db\StockItem;
use OCA\Mitgliederverwaltung\Db\StockItemMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\InventoryService;
use OCP\AppFramework\Db\DoesNotExistException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class InventoryServiceTest extends TestCase {
private InventoryService $service;
private InventoryCategoryMapper&MockObject $categoryMapper;
private InventoryItemCategoryMapper&MockObject $itemCategoryMapper;
private GeneralMaterialMapper&MockObject $generalMaterialMapper;
private StockItemMapper&MockObject $stockItemMapper;
private MockObject $stockVariantMapper;
private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger;
protected function setUp(): void {
parent::setUp();
$this->categoryMapper = $this->createMock(InventoryCategoryMapper::class);
$this->itemCategoryMapper = $this->createMock(InventoryItemCategoryMapper::class);
$this->generalMaterialMapper = $this->createMock(GeneralMaterialMapper::class);
$this->stockItemMapper = $this->createMock(StockItemMapper::class);
$this->stockVariantMapper = $this->getMockBuilder(\OCA\Mitgliederverwaltung\Db\StockVariantMapper::class)
->disableOriginalConstructor()
->onlyMethods(['findByStockItemId', 'findById', 'insert', 'update', 'delete'])
->getMock();
$this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new InventoryService(
$this->categoryMapper,
$this->itemCategoryMapper,
$this->generalMaterialMapper,
$this->stockItemMapper,
$this->stockVariantMapper,
$this->auditService,
$this->logger
);
}
private function generalMaterialEntity(int $id = 1, string $name = 'Zelt A', ?int $condition = 3): GeneralMaterial {
$entity = new GeneralMaterial();
$entity->setId($id);
$entity->setName($name);
$entity->setCondition($condition);
$entity->setNotes(null);
$entity->setCreatedAt('2026-04-21 00:00:00');
$entity->setUpdatedAt('2026-04-21 00:00:00');
return $entity;
}
private function categoryEntity(int $id = 1, string $name = 'Zelte'): InventoryCategory {
$entity = new InventoryCategory();
$entity->setId($id);
$entity->setName($name);
$entity->setCreatedAt('2026-04-21 00:00:00');
return $entity;
}
private function itemCategoryEntity(int $itemId = 1, int $categoryId = 1): InventoryItemCategory {
$entity = new InventoryItemCategory();
$entity->setItemId($itemId);
$entity->setCategoryId($categoryId);
return $entity;
}
public function testCreateGeneralMaterial_auditLogged(): void {
$gm = $this->generalMaterialEntity(1, 'Zelt A', 4);
$this->generalMaterialMapper->method('insert')->willReturnCallback(function ($entity) use ($gm) {
$entity->setId($gm->getId());
});
$this->categoryMapper->method('findById')->willReturn($this->categoryEntity(1, 'Zelte'));
$this->itemCategoryMapper->method('findByItemId')->willReturn([$this->itemCategoryEntity(1, 1)]);
$this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) {
$this->assertSame('Zelt A', $data['name']);
$this->assertSame(4, $data['condition']);
$this->assertSame('inventory_general_material', $type);
$this->assertSame(1, $id);
});
$result = $this->service->createGeneralMaterial([
'name' => 'Zelt A',
'condition' => 4,
'notes' => null,
'categories' => [1],
]);
$this->assertSame('Zelt A', $result->getName());
}
public function testCreateStockItem_auditLogged(): void {
$this->stockItemMapper->method('insert')->willReturnCallback(function ($entity) {
$entity->setId(1);
$entity->setName('Pfadfinderhemd');
});
$this->itemCategoryMapper->method('findByItemId')->willReturn([]);
$this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) {
$this->assertSame('Pfadfinderhemd', $data['name']);
$this->assertSame('inventory_stock_item', $type);
$this->assertSame(1, $id);
});
$result = $this->service->createStockItem([
'name' => 'Pfadfinderhemd',
'categories' => null,
]);
$this->assertSame('Pfadfinderhemd', $result->getName());
}
public function testDelete_auditLogged(): void {
$gm = $this->generalMaterialEntity(1, 'Zelt A');
$this->generalMaterialMapper->method('findById')->willReturn($gm);
$this->auditService->method('logDelete')->willReturnCallback(function(string $type, int $id) {
$this->assertSame('inventory_general_material', $type);
$this->assertSame(1, $id);
});
$this->service->deleteGeneralMaterial(1);
$this->assertTrue(true);
}
public function testConditionAlerts_correctThreshold(): void {
$this->assertFalse($this->service->needsRepair(3));
$this->assertFalse($this->service->needsRepair(4));
$this->assertFalse($this->service->needsRepair(5));
$this->assertTrue($this->service->needsRepair(2));
$this->assertTrue($this->service->needsRepair(1));
$this->assertTrue($this->service->needsRepair(0));
$this->assertTrue($this->service->needsRepair(null));
}
public function testGetConditionLabel(): void {
$this->assertSame('Nicht bewertet', $this->service->getConditionLabel(null));
$this->assertSame('1/5', $this->service->getConditionLabel(1));
$this->assertSame('3/5', $this->service->getConditionLabel(3));
$this->assertSame('5/5', $this->service->getConditionLabel(5));
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Service;
use OCA\Mitgliederverwaltung\Db\SaleRecord;
use OCA\Mitgliederverwaltung\Db\SaleRecordMapper;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCA\Mitgliederverwaltung\Service\SaleService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class SaleServiceTest extends TestCase {
private SaleService $service;
private SaleRecordMapper&MockObject $saleRecordMapper;
private AuditService&MockObject $auditService;
private LoggerInterface&MockObject $logger;
protected function setUp(): void {
parent::setUp();
$this->saleRecordMapper = $this->createMock(SaleRecordMapper::class);
$this->auditService = $this->createMock(AuditService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new SaleService(
$this->saleRecordMapper,
$this->auditService,
$this->logger
);
}
private function saleEntity(int $id = 1, int $stockItemId = 1, int $variantId = 1, int $quantity = 2, string $unitPrice = '25.00'): SaleRecord {
$entity = new SaleRecord();
$entity->setId($id);
$entity->setStockItemId($stockItemId);
$entity->setVariantId($variantId);
$entity->setDate('2026-04-21');
$entity->setQuantity($quantity);
$entity->setUnitPrice($unitPrice);
$entity->setTotalPrice((string)((float)$unitPrice * $quantity));
$entity->setNotes(null);
$entity->setCreatedAt('2026-04-21 00:00:00');
return $entity;
}
public function testCreateSale_auditLogged(): void {
$this->saleRecordMapper->method('insert')->willReturnCallback(function ($entity) use ($stockItemId = 1) {
$entity->setId(1);
});
$this->auditService->method('logCreate')->willReturnCallback(function(array $data, string $type, int $id) {
$this->assertSame(1, $data['stock_item_id']);
$this->assertSame('inventory_sale', $type);
$this->assertSame(1, $id);
});
$result = $this->service->createSale([
'stock_item_id' => 1,
'variant_id' => 1,
'date' => '2026-04-21',
'quantity' => 2,
'unit_price' => '25.00',
'notes' => null,
]);
$this->assertSame('50.00', $result->getTotalPrice());
}
public function testSaleRecord_totalCalculation(): void {
$sale = $this->saleEntity(1, 1, 1, 3, '15.00');
$expectedTotal = (float)$sale->getQuantity() * (float)$sale->getUnitPrice();
$actualTotal = (float)$sale->getTotalPrice();
$this->assertEqualsWithDelta($expectedTotal, $actualTotal, 0.01);
}
public function testSaleRecord_totalCalculationZero(): void {
$sale = $this->saleEntity(1, 1, 1, 0, '0.00');
$expectedTotal = 0.0;
$actualTotal = (float)$sale->getTotalPrice();
$this->assertEqualsWithDelta($expectedTotal, $actualTotal, 0.01);
}
}
+6
View File
@@ -48,8 +48,14 @@ class BundleImportServiceTest extends TestCase {
}
$zip->close();
if (!file_exists($tmpFile)) {
return '';
}
$content = file_get_contents($tmpFile);
unlink($tmpFile);
if ($content === false) {
return '';
}
return $content;
}
+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.4.0'),
}),
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,