diff --git a/apps/inventory/CLAUDE.md b/apps/inventory/CLAUDE.md new file mode 100644 index 000000000..5edbc7472 --- /dev/null +++ b/apps/inventory/CLAUDE.md @@ -0,0 +1,430 @@ +# Inventory Project Guide + +## Overview + +**Inventory** ist eine App zur Verwaltung von persönlichem Besitz und Inventar. Erfasse Gegenstände mit Fotos, Kaufbelegen, Garantie-Dokumenten, Kategorien und Standorten. + +| App | Port | URL | +| ------- | ---- | ------------------------- | +| Backend | 3020 | http://localhost:3020 | +| Web App | 5188 | http://localhost:5188 | +| Landing | 4325 | http://localhost:4325 | + +## Project Structure + +``` +apps/inventory/ +├── apps/ +│ ├── backend/ # NestJS API server (@inventory/backend) +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── db/ +│ │ │ ├── database.module.ts +│ │ │ ├── connection.ts +│ │ │ └── schema/ +│ │ │ ├── items.schema.ts +│ │ │ ├── categories.schema.ts +│ │ │ ├── locations.schema.ts +│ │ │ ├── item-photos.schema.ts +│ │ │ ├── item-documents.schema.ts +│ │ │ └── item-contacts.schema.ts +│ │ ├── item/ +│ │ ├── category/ +│ │ ├── location/ +│ │ ├── photo/ +│ │ ├── document/ +│ │ ├── storage/ +│ │ ├── import/ +│ │ ├── export/ +│ │ └── health/ +│ │ +│ ├── web/ # SvelteKit web app (@inventory/web) +│ │ └── src/ +│ │ ├── lib/ +│ │ │ ├── api/ +│ │ │ ├── stores/ +│ │ │ ├── components/ +│ │ │ └── i18n/ +│ │ └── routes/ +│ │ ├── +layout.svelte +│ │ ├── (auth)/ +│ │ └── (app)/ +│ │ +│ └── landing/ # Astro landing page (@inventory/landing) +│ +├── packages/ +│ └── shared/ # Shared types & constants (@inventory/shared) +│ +├── package.json +└── CLAUDE.md +``` + +## Commands + +### Root Level (from monorepo root) + +```bash +# Alle Apps starten +pnpm inventory:dev # Run all inventory apps + +# Einzelne Apps starten +pnpm dev:inventory:backend # Start backend server (port 3018) +pnpm dev:inventory:web # Start web app (port 5188) +pnpm dev:inventory:landing # Start landing page (port 4325) +pnpm dev:inventory:app # Start web + backend together + +# Datenbank +pnpm inventory:db:push # Push schema to database +pnpm inventory:db:studio # Open Drizzle Studio +pnpm inventory:db:seed # Seed initial data + +# Deploy +pnpm deploy:landing:inventory # Deploy landing to Cloudflare Pages +``` + +### Backend (apps/inventory/apps/backend) + +```bash +pnpm dev # Start with hot reload +pnpm build # Build for production +pnpm start:prod # Start production server +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +pnpm db:seed # Seed initial data +``` + +### Web App (apps/inventory/apps/web) + +```bash +pnpm dev # Start dev server +pnpm build # Build for production +pnpm preview # Preview production build +``` + +### Landing Page (apps/inventory/apps/landing) + +```bash +pnpm dev # Start dev server (port 4325) +pnpm build # Build for production +pnpm preview # Preview build +``` + +## Technology Stack + +| Layer | Technology | +| ----------- | ------------------------------------- | +| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL | +| **Web** | SvelteKit 2.x, Svelte 5, Tailwind CSS | +| **Landing** | Astro 5.x, Tailwind CSS | +| **Auth** | Mana Core Auth (JWT) | +| **Storage** | MinIO/S3 via @manacore/shared-storage | +| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) | + +## Features + +### 1. Items verwalten +- Erstelle/Bearbeite/Lösche Gegenstände +- Name, Beschreibung, SKU (optional) +- Kaufdatum, Kaufpreis, Währung +- Aktueller Wert, Zustand (neu/sehr gut/gut/akzeptabel/schlecht) +- Garantie-Ablauf und Notizen +- Favoriten und Archiv-Funktion + +### 2. Fotos +- Multiple Fotos pro Gegenstand +- Primärfoto-Auswahl +- Drag & Drop Upload +- Bildunterschriften + +### 3. Dokumente +- Kaufbelege, Garantiescheine, Handbücher +- PDF/Bild-Upload +- Dokument-Typen (receipt, warranty, manual, other) + +### 4. Kategorien (hierarchisch) +- Verschachtelte Kategorien (z.B. Elektronik > Computer > Laptops) +- Icons und Farben +- Drag & Drop Sortierung + +### 5. Standorte (hierarchisch) +- Verschachtelte Orte (z.B. Haus > Wohnzimmer > Regal) +- Beschreibung pro Standort + +### 6. Import/Export +- CSV Import mit Vorschau +- CSV Export (alle oder gefiltert) +- Vorlagen-Download + +### 7. Contacts-Integration +- Verknüpfe Gegenstände mit Kontakten +- Beziehungstypen: Verkäufer, Hersteller, Service + +## API Endpoints + +### Health +``` +GET /api/v1/health # Health check +``` + +### Items +``` +GET /api/v1/items # List items with filters +POST /api/v1/items # Create item +GET /api/v1/items/:id # Get item with photos, documents +PUT /api/v1/items/:id # Update item +DELETE /api/v1/items/:id # Soft delete (archive) +PATCH /api/v1/items/:id/toggle-favorite +PATCH /api/v1/items/:id/toggle-archive +``` + +### Photos +``` +POST /api/v1/items/:id/photos # Upload photos +DELETE /api/v1/items/:id/photos/:photoId # Delete photo +PATCH /api/v1/items/:id/photos/:photoId/set-primary +PATCH /api/v1/items/:id/photos/reorder # Reorder photos +``` + +### Documents +``` +POST /api/v1/items/:id/documents # Upload document +DELETE /api/v1/items/:id/documents/:docId # Delete document +GET /api/v1/items/:id/documents/:docId/download +``` + +### Categories +``` +GET /api/v1/categories # List all categories (tree) +POST /api/v1/categories # Create category +PATCH /api/v1/categories/:id # Update category +DELETE /api/v1/categories/:id # Delete category +``` + +### Locations +``` +GET /api/v1/locations # List all locations (tree) +POST /api/v1/locations # Create location +PATCH /api/v1/locations/:id # Update location +DELETE /api/v1/locations/:id # Delete location +``` + +### Import/Export +``` +POST /api/v1/import/csv # Import from CSV +GET /api/v1/import/template # Download CSV template +GET /api/v1/export/csv # Export to CSV +POST /api/v1/export/csv # Export with filters +``` + +## Database Schema + +### items +| Column | Type | Description | +| --------------- | ------------ | ------------------------- | +| `id` | UUID | Primary key | +| `user_id` | VARCHAR(255) | Owner | +| `name` | VARCHAR(255) | Item name | +| `description` | TEXT | Description | +| `sku` | VARCHAR(100) | Stock keeping unit | +| `category_id` | UUID | Category reference | +| `location_id` | UUID | Location reference | +| `purchase_date` | DATE | When purchased | +| `purchase_price`| DECIMAL | Purchase price | +| `currency` | VARCHAR(3) | Currency code (EUR, USD) | +| `current_value` | DECIMAL | Current estimated value | +| `condition` | VARCHAR(20) | new/like_new/good/fair/poor | +| `warranty_expires` | DATE | Warranty expiration | +| `warranty_notes`| TEXT | Warranty details | +| `notes` | TEXT | Additional notes | +| `quantity` | INTEGER | Quantity (default: 1) | +| `is_favorite` | BOOLEAN | Favorited | +| `is_archived` | BOOLEAN | Archived (soft delete) | +| `created_at` | TIMESTAMP | Created timestamp | +| `updated_at` | TIMESTAMP | Updated timestamp | + +### categories +| Column | Type | Description | +| ------------------- | ------------ | -------------------- | +| `id` | UUID | Primary key | +| `user_id` | VARCHAR(255) | Owner | +| `name` | VARCHAR(100) | Category name | +| `icon` | VARCHAR(50) | Icon identifier | +| `color` | VARCHAR(7) | Hex color | +| `parent_category_id`| UUID | Parent for hierarchy | +| `created_at` | TIMESTAMP | Created timestamp | + +### locations +| Column | Type | Description | +| ------------------- | ------------ | -------------------- | +| `id` | UUID | Primary key | +| `user_id` | VARCHAR(255) | Owner | +| `name` | VARCHAR(100) | Location name | +| `description` | TEXT | Description | +| `parent_location_id`| UUID | Parent for hierarchy | +| `created_at` | TIMESTAMP | Created timestamp | + +### item_photos +| Column | Type | Description | +| ------------ | ------------ | -------------------- | +| `id` | UUID | Primary key | +| `item_id` | UUID | Item reference | +| `storage_key`| VARCHAR(500) | S3 storage key | +| `is_primary` | BOOLEAN | Is primary photo | +| `caption` | VARCHAR(255) | Photo caption | +| `sort_order` | INTEGER | Display order | +| `created_at` | TIMESTAMP | Upload timestamp | + +### item_documents +| Column | Type | Description | +| -------------- | ------------ | ---------------------- | +| `id` | UUID | Primary key | +| `item_id` | UUID | Item reference | +| `storage_key` | VARCHAR(500) | S3 storage key | +| `document_type`| VARCHAR(20) | receipt/warranty/manual/other | +| `filename` | VARCHAR(255) | Original filename | +| `mime_type` | VARCHAR(100) | MIME type | +| `file_size` | BIGINT | File size in bytes | +| `uploaded_at` | TIMESTAMP | Upload timestamp | + +### item_contacts +| Column | Type | Description | +| ----------------- | ------------ | ------------------------- | +| `id` | UUID | Primary key | +| `item_id` | UUID | Item reference | +| `contact_id` | UUID | Contact reference (from Contacts app) | +| `relationship_type`| VARCHAR(20) | seller/manufacturer/service | +| `created_at` | TIMESTAMP | Created timestamp | + +## Environment Variables + +### Backend (.env) +```env +NODE_ENV=development +PORT=3020 +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/inventory +MANA_CORE_AUTH_URL=http://localhost:3001 +CORS_ORIGINS=http://localhost:5173,http://localhost:5188,http://localhost:8081 +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +INVENTORY_S3_PUBLIC_URL=http://localhost:9000/inventory-storage +DEV_BYPASS_AUTH=true +DEV_USER_ID=your-test-user-id +``` + +### Web (.env) +```env +PUBLIC_BACKEND_URL=http://localhost:3018 +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Web App Stores (Svelte 5 Runes) + +```typescript +// auth.svelte.ts - Authentication +authStore.isAuthenticated +authStore.user +authStore.signIn(email, password) +authStore.signOut() +authStore.getAccessToken() + +// items.svelte.ts - Items +itemsStore.items +itemsStore.selectedItem +itemsStore.loading +itemsStore.fetchItems(filters) +itemsStore.createItem(data) +itemsStore.updateItem(id, data) +itemsStore.deleteItem(id) +itemsStore.toggleFavorite(id) +itemsStore.toggleArchive(id) + +// categories.svelte.ts - Categories +categoriesStore.categories +categoriesStore.categoryTree +categoriesStore.fetchCategories() +categoriesStore.createCategory(data) +categoriesStore.updateCategory(id, data) +categoriesStore.deleteCategory(id) + +// locations.svelte.ts - Locations +locationsStore.locations +locationsStore.locationTree +locationsStore.fetchLocations() +locationsStore.createLocation(data) +locationsStore.updateLocation(id, data) +locationsStore.deleteLocation(id) +``` + +## Quick Start + +### 1. Datenbank erstellen + +```bash +# PostgreSQL Container muss laufen +docker compose -f docker-compose.dev.yml up -d postgres + +# Datenbank erstellen +PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE inventory;" + +# Schema pushen +pnpm inventory:db:push +``` + +### 2. Apps starten + +```bash +# Backend + Web zusammen +pnpm dev:inventory:app + +# Oder einzeln: +pnpm dev:inventory:backend # Terminal 1 +pnpm dev:inventory:web # Terminal 2 +pnpm dev:inventory:landing # Terminal 3 (optional) +``` + +### 3. URLs öffnen + +- Web App: http://localhost:5188 +- Landing: http://localhost:4325 +- API Health: http://localhost:3018/api/v1/health + +## Testing API (mit curl) + +```bash +# Health Check +curl http://localhost:3020/api/v1/health + +# Login (get token) +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken') + +# Items abrufen +curl http://localhost:3020/api/v1/items \ + -H "Authorization: Bearer $TOKEN" + +# Neues Item erstellen +curl -X POST http://localhost:3018/api/v1/items \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "MacBook Pro", "purchasePrice": 2499.00, "currency": "EUR"}' + +# Kategorie erstellen +curl -X POST http://localhost:3018/api/v1/categories \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "Elektronik", "icon": "laptop", "color": "#3B82F6"}' +``` + +## Important Notes + +1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header) +2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432) +3. **Port**: Backend läuft auf Port 3018, Web auf 5188, Landing auf 4325 +4. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT) +5. **Theme**: Teal/Cyan (#14B8A6) als Primärfarbe +6. **Storage**: Nutzt MinIO/S3 für Fotos und Dokumente via @manacore/shared-storage +7. **Contacts**: Integration mit Contacts-App für Verkäufer/Hersteller-Verknüpfung diff --git a/apps/inventory/apps/backend/drizzle.config.ts b/apps/inventory/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..0d6e60663 --- /dev/null +++ b/apps/inventory/apps/backend/drizzle.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default defineConfig({ + schema: './src/db/schema/index.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/inventory', + }, + verbose: true, + strict: true, +}); diff --git a/apps/inventory/apps/backend/nest-cli.json b/apps/inventory/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/inventory/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/inventory/apps/backend/package.json b/apps/inventory/apps/backend/package.json new file mode 100644 index 000000000..18917a446 --- /dev/null +++ b/apps/inventory/apps/backend/package.json @@ -0,0 +1,58 @@ +{ + "name": "@inventory/backend", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "dev": "nest start --watch", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio", + "db:seed": "tsx src/db/seed.ts" + }, + "dependencies": { + "@inventory/shared": "workspace:*", + "@manacore/shared-nestjs-auth": "workspace:*", + "@manacore/shared-storage": "workspace:*", + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "csv-parse": "^6.1.0", + "csv-stringify": "^6.5.0", + "dotenv": "^16.4.7", + "drizzle-kit": "^0.30.2", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.11", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/inventory/apps/backend/src/app.module.ts b/apps/inventory/apps/backend/src/app.module.ts new file mode 100644 index 000000000..1e7aba712 --- /dev/null +++ b/apps/inventory/apps/backend/src/app.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { StorageModule } from './storage/storage.module'; +import { HealthModule } from './health/health.module'; +import { ItemModule } from './item/item.module'; +import { CategoryModule } from './category/category.module'; +import { LocationModule } from './location/location.module'; +import { PhotoModule } from './photo/photo.module'; +import { DocumentModule } from './document/document.module'; +import { ImportModule } from './import/import.module'; +import { ExportModule } from './export/export.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', '.env.development'], + }), + DatabaseModule, + StorageModule, + HealthModule, + ItemModule, + CategoryModule, + LocationModule, + PhotoModule, + DocumentModule, + ImportModule, + ExportModule, + ], +}) +export class AppModule {} diff --git a/apps/inventory/apps/backend/src/category/category.controller.ts b/apps/inventory/apps/backend/src/category/category.controller.ts new file mode 100644 index 000000000..dbca3dac0 --- /dev/null +++ b/apps/inventory/apps/backend/src/category/category.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { CategoryService } from './category.service'; +import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto'; + +@Controller('api/v1/categories') +@UseGuards(JwtAuthGuard) +export class CategoryController { + constructor(private readonly categoryService: CategoryService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + return this.categoryService.findAll(user.userId); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.categoryService.findOne(user.userId, id); + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCategoryDto) { + return this.categoryService.create(user.userId, dto); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateCategoryDto + ) { + return this.categoryService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.categoryService.delete(user.userId, id); + } +} diff --git a/apps/inventory/apps/backend/src/category/category.module.ts b/apps/inventory/apps/backend/src/category/category.module.ts new file mode 100644 index 000000000..8a7ff5cfa --- /dev/null +++ b/apps/inventory/apps/backend/src/category/category.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CategoryController } from './category.controller'; +import { CategoryService } from './category.service'; + +@Module({ + controllers: [CategoryController], + providers: [CategoryService], + exports: [CategoryService], +}) +export class CategoryModule {} diff --git a/apps/inventory/apps/backend/src/category/category.service.ts b/apps/inventory/apps/backend/src/category/category.service.ts new file mode 100644 index 000000000..3ed45703c --- /dev/null +++ b/apps/inventory/apps/backend/src/category/category.service.ts @@ -0,0 +1,159 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and, isNull } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { categories, items } from '../db/schema'; +import { CreateCategoryDto, UpdateCategoryDto } from './dto/category.dto'; + +export interface CategoryWithChildren { + id: string; + userId: string; + name: string; + icon: string | null; + color: string | null; + parentCategoryId: string | null; + createdAt: Date; + children: CategoryWithChildren[]; +} + +@Injectable() +export class CategoryService { + constructor(@Inject(DATABASE_CONNECTION) private db: DbClient) {} + + async findAll(userId: string) { + const allCategories = await this.db + .select() + .from(categories) + .where(eq(categories.userId, userId)); + + return this.buildTree(allCategories); + } + + async findOne(userId: string, id: string) { + const result = await this.db + .select() + .from(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))) + .limit(1); + + if (!result.length) { + throw new NotFoundException('Category not found'); + } + + return result[0]; + } + + async create(userId: string, dto: CreateCategoryDto) { + if (dto.parentCategoryId) { + const parent = await this.db + .select() + .from(categories) + .where(and(eq(categories.id, dto.parentCategoryId), eq(categories.userId, userId))) + .limit(1); + + if (!parent.length) { + throw new BadRequestException('Parent category not found'); + } + } + + const [category] = await this.db + .insert(categories) + .values({ + userId, + name: dto.name, + icon: dto.icon, + color: dto.color, + parentCategoryId: dto.parentCategoryId, + }) + .returning(); + + return category; + } + + async update(userId: string, id: string, dto: UpdateCategoryDto) { + const existing = await this.db + .select() + .from(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Category not found'); + } + + if (dto.parentCategoryId) { + if (dto.parentCategoryId === id) { + throw new BadRequestException('Category cannot be its own parent'); + } + + const parent = await this.db + .select() + .from(categories) + .where(and(eq(categories.id, dto.parentCategoryId), eq(categories.userId, userId))) + .limit(1); + + if (!parent.length) { + throw new BadRequestException('Parent category not found'); + } + } + + const [category] = await this.db + .update(categories) + .set({ + name: dto.name ?? existing[0].name, + icon: dto.icon ?? existing[0].icon, + color: dto.color ?? existing[0].color, + parentCategoryId: dto.parentCategoryId ?? existing[0].parentCategoryId, + }) + .where(eq(categories.id, id)) + .returning(); + + return category; + } + + async delete(userId: string, id: string) { + const existing = await this.db + .select() + .from(categories) + .where(and(eq(categories.id, id), eq(categories.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Category not found'); + } + + await this.db + .update(categories) + .set({ parentCategoryId: null }) + .where(eq(categories.parentCategoryId, id)); + await this.db.update(items).set({ categoryId: null }).where(eq(items.categoryId, id)); + await this.db.delete(categories).where(eq(categories.id, id)); + + return { success: true }; + } + + private buildTree(allCategories: (typeof categories.$inferSelect)[]): CategoryWithChildren[] { + const map = new Map(); + const roots: CategoryWithChildren[] = []; + + for (const cat of allCategories) { + map.set(cat.id, { ...cat, children: [] }); + } + + for (const cat of allCategories) { + const node = map.get(cat.id)!; + if (cat.parentCategoryId) { + const parent = map.get(cat.parentCategoryId); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } else { + roots.push(node); + } + } + + return roots; + } +} diff --git a/apps/inventory/apps/backend/src/category/dto/category.dto.ts b/apps/inventory/apps/backend/src/category/dto/category.dto.ts new file mode 100644 index 000000000..24c0636af --- /dev/null +++ b/apps/inventory/apps/backend/src/category/dto/category.dto.ts @@ -0,0 +1,42 @@ +import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator'; + +export class CreateCategoryDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsUUID() + parentCategoryId?: string; +} + +export class UpdateCategoryDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsUUID() + parentCategoryId?: string; +} diff --git a/apps/inventory/apps/backend/src/db/connection.ts b/apps/inventory/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..ee9328854 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/connection.ts @@ -0,0 +1,11 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +const connectionString = + process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/inventory'; + +const client = postgres(connectionString); +export const db = drizzle(client, { schema }); + +export type DbClient = typeof db; diff --git a/apps/inventory/apps/backend/src/db/database.module.ts b/apps/inventory/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..bb97aae30 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/database.module.ts @@ -0,0 +1,16 @@ +import { Module, Global } from '@nestjs/common'; +import { db } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useValue: db, + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/apps/inventory/apps/backend/src/db/schema/categories.schema.ts b/apps/inventory/apps/backend/src/db/schema/categories.schema.ts new file mode 100644 index 000000000..a4e5b025d --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/categories.schema.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { items } from './items.schema'; + +export const categories = pgTable('categories', { + id: uuid('id').defaultRandom().primaryKey(), + userId: varchar('user_id', { length: 255 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + icon: varchar('icon', { length: 50 }), + color: varchar('color', { length: 20 }), + parentCategoryId: uuid('parent_category_id'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const categoriesRelations = relations(categories, ({ one, many }) => ({ + parent: one(categories, { + fields: [categories.parentCategoryId], + references: [categories.id], + relationName: 'categoryHierarchy', + }), + children: many(categories, { relationName: 'categoryHierarchy' }), + items: many(items), +})); + +export type Category = typeof categories.$inferSelect; +export type NewCategory = typeof categories.$inferInsert; diff --git a/apps/inventory/apps/backend/src/db/schema/index.ts b/apps/inventory/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..136be6997 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/index.ts @@ -0,0 +1,6 @@ +export * from './items.schema'; +export * from './categories.schema'; +export * from './locations.schema'; +export * from './item-photos.schema'; +export * from './item-documents.schema'; +export * from './item-contacts.schema'; diff --git a/apps/inventory/apps/backend/src/db/schema/item-contacts.schema.ts b/apps/inventory/apps/backend/src/db/schema/item-contacts.schema.ts new file mode 100644 index 000000000..e857e6478 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/item-contacts.schema.ts @@ -0,0 +1,28 @@ +import { pgTable, uuid, varchar, timestamp, primaryKey } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { items } from './items.schema'; + +export const itemContacts = pgTable( + 'item_contacts', + { + itemId: uuid('item_id') + .notNull() + .references(() => items.id, { onDelete: 'cascade' }), + contactId: uuid('contact_id').notNull(), + relationshipType: varchar('relationship_type', { length: 20 }).default('seller').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.itemId, table.contactId] }), + }) +); + +export const itemContactsRelations = relations(itemContacts, ({ one }) => ({ + item: one(items, { + fields: [itemContacts.itemId], + references: [items.id], + }), +})); + +export type ItemContact = typeof itemContacts.$inferSelect; +export type NewItemContact = typeof itemContacts.$inferInsert; diff --git a/apps/inventory/apps/backend/src/db/schema/item-documents.schema.ts b/apps/inventory/apps/backend/src/db/schema/item-documents.schema.ts new file mode 100644 index 000000000..98bdcffea --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/item-documents.schema.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, varchar, text, integer, timestamp } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { items } from './items.schema'; + +export const itemDocuments = pgTable('item_documents', { + id: uuid('id').defaultRandom().primaryKey(), + itemId: uuid('item_id') + .notNull() + .references(() => items.id, { onDelete: 'cascade' }), + storageKey: varchar('storage_key', { length: 500 }).notNull(), + documentType: varchar('document_type', { length: 20 }).default('other').notNull(), + filename: varchar('filename', { length: 255 }).notNull(), + mimeType: varchar('mime_type', { length: 100 }), + fileSize: integer('file_size'), + uploadedAt: timestamp('uploaded_at').defaultNow().notNull(), +}); + +export const itemDocumentsRelations = relations(itemDocuments, ({ one }) => ({ + item: one(items, { + fields: [itemDocuments.itemId], + references: [items.id], + }), +})); + +export type ItemDocument = typeof itemDocuments.$inferSelect; +export type NewItemDocument = typeof itemDocuments.$inferInsert; diff --git a/apps/inventory/apps/backend/src/db/schema/item-photos.schema.ts b/apps/inventory/apps/backend/src/db/schema/item-photos.schema.ts new file mode 100644 index 000000000..bb0cad64a --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/item-photos.schema.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, varchar, text, boolean, integer, timestamp } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { items } from './items.schema'; + +export const itemPhotos = pgTable('item_photos', { + id: uuid('id').defaultRandom().primaryKey(), + itemId: uuid('item_id') + .notNull() + .references(() => items.id, { onDelete: 'cascade' }), + storageKey: varchar('storage_key', { length: 500 }).notNull(), + isPrimary: boolean('is_primary').default(false).notNull(), + caption: text('caption'), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const itemPhotosRelations = relations(itemPhotos, ({ one }) => ({ + item: one(items, { + fields: [itemPhotos.itemId], + references: [items.id], + }), +})); + +export type ItemPhoto = typeof itemPhotos.$inferSelect; +export type NewItemPhoto = typeof itemPhotos.$inferInsert; diff --git a/apps/inventory/apps/backend/src/db/schema/items.schema.ts b/apps/inventory/apps/backend/src/db/schema/items.schema.ts new file mode 100644 index 000000000..fef49fb73 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/items.schema.ts @@ -0,0 +1,56 @@ +import { + pgTable, + uuid, + varchar, + text, + boolean, + timestamp, + decimal, + date, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { categories } from './categories.schema'; +import { locations } from './locations.schema'; +import { itemPhotos } from './item-photos.schema'; +import { itemDocuments } from './item-documents.schema'; +import { itemContacts } from './item-contacts.schema'; + +export const items = pgTable('items', { + id: uuid('id').defaultRandom().primaryKey(), + userId: varchar('user_id', { length: 255 }).notNull(), + name: varchar('name', { length: 255 }).notNull(), + description: text('description'), + sku: varchar('sku', { length: 100 }), + categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }), + locationId: uuid('location_id').references(() => locations.id, { onDelete: 'set null' }), + purchaseDate: date('purchase_date'), + purchasePrice: decimal('purchase_price', { precision: 12, scale: 2 }), + currency: varchar('currency', { length: 3 }).default('EUR').notNull(), + currentValue: decimal('current_value', { precision: 12, scale: 2 }), + condition: varchar('condition', { length: 20 }).default('good').notNull(), + warrantyExpires: date('warranty_expires'), + warrantyNotes: text('warranty_notes'), + notes: text('notes'), + quantity: decimal('quantity', { precision: 10, scale: 2 }).default('1').notNull(), + isFavorite: boolean('is_favorite').default(false).notNull(), + isArchived: boolean('is_archived').default(false).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +export const itemsRelations = relations(items, ({ one, many }) => ({ + category: one(categories, { + fields: [items.categoryId], + references: [categories.id], + }), + location: one(locations, { + fields: [items.locationId], + references: [locations.id], + }), + photos: many(itemPhotos), + documents: many(itemDocuments), + contacts: many(itemContacts), +})); + +export type Item = typeof items.$inferSelect; +export type NewItem = typeof items.$inferInsert; diff --git a/apps/inventory/apps/backend/src/db/schema/locations.schema.ts b/apps/inventory/apps/backend/src/db/schema/locations.schema.ts new file mode 100644 index 000000000..3b62489a5 --- /dev/null +++ b/apps/inventory/apps/backend/src/db/schema/locations.schema.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, varchar, text, timestamp } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { items } from './items.schema'; + +export const locations = pgTable('locations', { + id: uuid('id').defaultRandom().primaryKey(), + userId: varchar('user_id', { length: 255 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + parentLocationId: uuid('parent_location_id'), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +export const locationsRelations = relations(locations, ({ one, many }) => ({ + parent: one(locations, { + fields: [locations.parentLocationId], + references: [locations.id], + relationName: 'locationHierarchy', + }), + children: many(locations, { relationName: 'locationHierarchy' }), + items: many(items), +})); + +export type Location = typeof locations.$inferSelect; +export type NewLocation = typeof locations.$inferInsert; diff --git a/apps/inventory/apps/backend/src/document/document.controller.ts b/apps/inventory/apps/backend/src/document/document.controller.ts new file mode 100644 index 000000000..5856fef4b --- /dev/null +++ b/apps/inventory/apps/backend/src/document/document.controller.ts @@ -0,0 +1,50 @@ +import { + Controller, + Post, + Delete, + Get, + Param, + UseGuards, + UseInterceptors, + UploadedFile, + Body, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { DocumentService } from './document.service'; +import type { DocumentType } from '@inventory/shared'; + +@Controller('api/v1/items/:itemId/documents') +@UseGuards(JwtAuthGuard) +export class DocumentController { + constructor(private readonly documentService: DocumentService) {} + + @Post() + @UseInterceptors(FileInterceptor('document', { limits: { fileSize: 20 * 1024 * 1024 } })) + async uploadDocument( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @UploadedFile() file: Express.Multer.File, + @Body('documentType') documentType?: DocumentType + ) { + return this.documentService.uploadDocument(user.userId, itemId, file, documentType); + } + + @Delete(':documentId') + async deleteDocument( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @Param('documentId') documentId: string + ) { + return this.documentService.deleteDocument(user.userId, itemId, documentId); + } + + @Get(':documentId/download') + async getDownloadUrl( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @Param('documentId') documentId: string + ) { + return this.documentService.getDownloadUrl(user.userId, itemId, documentId); + } +} diff --git a/apps/inventory/apps/backend/src/document/document.module.ts b/apps/inventory/apps/backend/src/document/document.module.ts new file mode 100644 index 000000000..6ea8b6c68 --- /dev/null +++ b/apps/inventory/apps/backend/src/document/document.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { DocumentController } from './document.controller'; +import { DocumentService } from './document.service'; + +@Module({ + imports: [ + MulterModule.register({ + limits: { fileSize: 20 * 1024 * 1024 }, + }), + ], + controllers: [DocumentController], + providers: [DocumentService], + exports: [DocumentService], +}) +export class DocumentModule {} diff --git a/apps/inventory/apps/backend/src/document/document.service.ts b/apps/inventory/apps/backend/src/document/document.service.ts new file mode 100644 index 000000000..197752c8c --- /dev/null +++ b/apps/inventory/apps/backend/src/document/document.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { items, itemDocuments } from '../db/schema'; +import { StorageService } from '../storage/storage.service'; +import type { DocumentType } from '@inventory/shared'; + +const ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', +]; + +@Injectable() +export class DocumentService { + constructor( + @Inject(DATABASE_CONNECTION) private db: DbClient, + private storageService: StorageService + ) {} + + async uploadDocument( + userId: string, + itemId: string, + file: Express.Multer.File, + documentType: DocumentType = 'other' + ) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { + throw new BadRequestException(`File type ${file.mimetype} is not allowed`); + } + + const { key } = await this.storageService.uploadDocument(userId, file); + + const [document] = await this.db + .insert(itemDocuments) + .values({ + itemId, + storageKey: key, + documentType, + filename: file.originalname, + mimeType: file.mimetype, + fileSize: file.size, + }) + .returning(); + + return document; + } + + async deleteDocument(userId: string, itemId: string, documentId: string) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + const document = await this.db + .select() + .from(itemDocuments) + .where(and(eq(itemDocuments.id, documentId), eq(itemDocuments.itemId, itemId))) + .limit(1); + + if (!document.length) { + throw new NotFoundException('Document not found'); + } + + await this.storageService.deleteFile(document[0].storageKey); + await this.db.delete(itemDocuments).where(eq(itemDocuments.id, documentId)); + + return { success: true }; + } + + async getDownloadUrl(userId: string, itemId: string, documentId: string) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + const document = await this.db + .select() + .from(itemDocuments) + .where(and(eq(itemDocuments.id, documentId), eq(itemDocuments.itemId, itemId))) + .limit(1); + + if (!document.length) { + throw new NotFoundException('Document not found'); + } + + const url = await this.storageService.getDownloadUrl(document[0].storageKey); + + return { url, filename: document[0].filename, mimeType: document[0].mimeType }; + } +} diff --git a/apps/inventory/apps/backend/src/export/export.controller.ts b/apps/inventory/apps/backend/src/export/export.controller.ts new file mode 100644 index 000000000..71dcd19eb --- /dev/null +++ b/apps/inventory/apps/backend/src/export/export.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Post, Body, Query, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ExportService } from './export.service'; + +class ExportOptionsDto { + categoryId?: string; + locationId?: string; + includeArchived?: boolean; +} + +@Controller('api/v1/export') +@UseGuards(JwtAuthGuard) +export class ExportController { + constructor(private readonly exportService: ExportService) {} + + @Get('csv') + async exportCsv( + @CurrentUser() user: CurrentUserData, + @Query() options: ExportOptionsDto, + @Res() res: Response + ) { + const csv = await this.exportService.exportCsv(user.userId, options); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=inventory-export-${new Date().toISOString().split('T')[0]}.csv` + ); + res.send(csv); + } + + @Post('csv') + async exportCsvWithOptions( + @CurrentUser() user: CurrentUserData, + @Body() options: ExportOptionsDto, + @Res() res: Response + ) { + const csv = await this.exportService.exportCsv(user.userId, options); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader( + 'Content-Disposition', + `attachment; filename=inventory-export-${new Date().toISOString().split('T')[0]}.csv` + ); + res.send(csv); + } +} diff --git a/apps/inventory/apps/backend/src/export/export.module.ts b/apps/inventory/apps/backend/src/export/export.module.ts new file mode 100644 index 000000000..feb591e6b --- /dev/null +++ b/apps/inventory/apps/backend/src/export/export.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ExportController } from './export.controller'; +import { ExportService } from './export.service'; + +@Module({ + controllers: [ExportController], + providers: [ExportService], + exports: [ExportService], +}) +export class ExportModule {} diff --git a/apps/inventory/apps/backend/src/export/export.service.ts b/apps/inventory/apps/backend/src/export/export.service.ts new file mode 100644 index 000000000..7765fc3af --- /dev/null +++ b/apps/inventory/apps/backend/src/export/export.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { stringify } from 'csv-stringify/sync'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { items, categories, locations } from '../db/schema'; + +interface ExportOptions { + categoryId?: string; + locationId?: string; + includeArchived?: boolean; +} + +@Injectable() +export class ExportService { + constructor(@Inject(DATABASE_CONNECTION) private db: DbClient) {} + + async exportCsv(userId: string, options: ExportOptions = {}): Promise { + const conditions = [eq(items.userId, userId)]; + + if (!options.includeArchived) { + conditions.push(eq(items.isArchived, false)); + } + if (options.categoryId) { + conditions.push(eq(items.categoryId, options.categoryId)); + } + if (options.locationId) { + conditions.push(eq(items.locationId, options.locationId)); + } + + const data = await this.db + .select({ + item: items, + category: categories, + location: locations, + }) + .from(items) + .leftJoin(categories, eq(items.categoryId, categories.id)) + .leftJoin(locations, eq(items.locationId, locations.id)) + .where(and(...conditions)); + + const rows = data.map(({ item, category, location }) => ({ + name: item.name, + description: item.description || '', + sku: item.sku || '', + category: category?.name || '', + location: location?.name || '', + purchaseDate: item.purchaseDate || '', + purchasePrice: item.purchasePrice || '', + currency: item.currency, + currentValue: item.currentValue || '', + condition: item.condition, + warrantyExpires: item.warrantyExpires || '', + warrantyNotes: item.warrantyNotes || '', + notes: item.notes || '', + quantity: item.quantity, + isFavorite: item.isFavorite ? 'yes' : 'no', + isArchived: item.isArchived ? 'yes' : 'no', + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })); + + return stringify(rows, { + header: true, + columns: [ + 'name', + 'description', + 'sku', + 'category', + 'location', + 'purchaseDate', + 'purchasePrice', + 'currency', + 'currentValue', + 'condition', + 'warrantyExpires', + 'warrantyNotes', + 'notes', + 'quantity', + 'isFavorite', + 'isArchived', + 'createdAt', + 'updatedAt', + ], + }); + } +} diff --git a/apps/inventory/apps/backend/src/health/health.controller.ts b/apps/inventory/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..87fe6e370 --- /dev/null +++ b/apps/inventory/apps/backend/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('api/v1/health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'inventory-backend', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/apps/inventory/apps/backend/src/health/health.module.ts b/apps/inventory/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/apps/inventory/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/inventory/apps/backend/src/import/import.controller.ts b/apps/inventory/apps/backend/src/import/import.controller.ts new file mode 100644 index 000000000..ea58ea063 --- /dev/null +++ b/apps/inventory/apps/backend/src/import/import.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Post, + Get, + UseGuards, + UseInterceptors, + UploadedFile, + Res, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ImportService } from './import.service'; + +@Controller('api/v1/import') +@UseGuards(JwtAuthGuard) +export class ImportController { + constructor(private readonly importService: ImportService) {} + + @Post('csv') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })) + async importCsv(@CurrentUser() user: CurrentUserData, @UploadedFile() file: Express.Multer.File) { + return this.importService.importCsv(user.userId, file); + } + + @Get('template') + async getTemplate(@Res() res: Response) { + const template = this.importService.getTemplate(); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=inventory-import-template.csv'); + res.send(template); + } +} diff --git a/apps/inventory/apps/backend/src/import/import.module.ts b/apps/inventory/apps/backend/src/import/import.module.ts new file mode 100644 index 000000000..a6476318e --- /dev/null +++ b/apps/inventory/apps/backend/src/import/import.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; + +@Module({ + imports: [ + MulterModule.register({ + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ], + controllers: [ImportController], + providers: [ImportService], + exports: [ImportService], +}) +export class ImportModule {} diff --git a/apps/inventory/apps/backend/src/import/import.service.ts b/apps/inventory/apps/backend/src/import/import.service.ts new file mode 100644 index 000000000..0f4838f7a --- /dev/null +++ b/apps/inventory/apps/backend/src/import/import.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Inject, BadRequestException } from '@nestjs/common'; +import { parse } from 'csv-parse/sync'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { items } from '../db/schema'; + +interface CsvRow { + name?: string; + description?: string; + sku?: string; + category?: string; + location?: string; + purchaseDate?: string; + purchasePrice?: string; + currency?: string; + currentValue?: string; + condition?: string; + warrantyExpires?: string; + notes?: string; + quantity?: string; +} + +@Injectable() +export class ImportService { + constructor(@Inject(DATABASE_CONNECTION) private db: DbClient) {} + + async importCsv(userId: string, file: Express.Multer.File) { + if (!file.mimetype.includes('csv') && !file.originalname.endsWith('.csv')) { + throw new BadRequestException('File must be CSV format'); + } + + const content = file.buffer.toString('utf-8'); + + let records: CsvRow[]; + try { + records = parse(content, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + } catch (error) { + throw new BadRequestException('Invalid CSV format'); + } + + if (!records.length) { + throw new BadRequestException('CSV file is empty'); + } + + const imported: string[] = []; + const errors: { row: number; error: string }[] = []; + + for (let i = 0; i < records.length; i++) { + const row = records[i]; + + if (!row.name?.trim()) { + errors.push({ row: i + 2, error: 'Name is required' }); + continue; + } + + try { + const [item] = await this.db + .insert(items) + .values({ + userId, + name: row.name.trim(), + description: row.description?.trim() || null, + sku: row.sku?.trim() || null, + purchaseDate: row.purchaseDate || null, + purchasePrice: row.purchasePrice || null, + currency: row.currency?.trim() || 'EUR', + currentValue: row.currentValue || null, + condition: this.normalizeCondition(row.condition), + warrantyExpires: row.warrantyExpires || null, + notes: row.notes?.trim() || null, + quantity: row.quantity || '1', + }) + .returning(); + + imported.push(item.id); + } catch (error) { + errors.push({ row: i + 2, error: error.message }); + } + } + + return { + imported: imported.length, + errors: errors.length, + errorDetails: errors, + itemIds: imported, + }; + } + + getTemplate(): string { + const headers = [ + 'name', + 'description', + 'sku', + 'purchaseDate', + 'purchasePrice', + 'currency', + 'currentValue', + 'condition', + 'warrantyExpires', + 'notes', + 'quantity', + ]; + + const exampleRow = [ + 'MacBook Pro 16"', + 'Work laptop', + 'MBP-2023-001', + '2023-06-15', + '2499.00', + 'EUR', + '2000.00', + 'good', + '2025-06-15', + 'Company device', + '1', + ]; + + return [headers.join(','), exampleRow.join(',')].join('\n'); + } + + private normalizeCondition(condition?: string): string { + const normalized = condition?.toLowerCase().trim(); + const validConditions = ['new', 'like_new', 'good', 'fair', 'poor']; + + if (normalized && validConditions.includes(normalized)) { + return normalized; + } + + const mapping: Record = { + neu: 'new', + new: 'new', + 'sehr gut': 'like_new', + 'like new': 'like_new', + likenew: 'like_new', + gut: 'good', + good: 'good', + akzeptabel: 'fair', + fair: 'fair', + schlecht: 'poor', + poor: 'poor', + }; + + return mapping[normalized || ''] || 'good'; + } +} diff --git a/apps/inventory/apps/backend/src/item/dto/create-item.dto.ts b/apps/inventory/apps/backend/src/item/dto/create-item.dto.ts new file mode 100644 index 000000000..052152e99 --- /dev/null +++ b/apps/inventory/apps/backend/src/item/dto/create-item.dto.ts @@ -0,0 +1,75 @@ +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + IsUUID, + IsDateString, + IsIn, + Min, +} from 'class-validator'; +import type { ItemCondition } from '@inventory/shared'; + +export class CreateItemDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsOptional() + @IsDateString() + purchaseDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + purchasePrice?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + @IsNumber() + @Min(0) + currentValue?: number; + + @IsOptional() + @IsIn(['new', 'like_new', 'good', 'fair', 'poor']) + condition?: ItemCondition; + + @IsOptional() + @IsDateString() + warrantyExpires?: string; + + @IsOptional() + @IsString() + warrantyNotes?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + @Min(0) + quantity?: number; + + @IsOptional() + @IsBoolean() + isFavorite?: boolean; +} diff --git a/apps/inventory/apps/backend/src/item/dto/index.ts b/apps/inventory/apps/backend/src/item/dto/index.ts new file mode 100644 index 000000000..c5bd8ced0 --- /dev/null +++ b/apps/inventory/apps/backend/src/item/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-item.dto'; +export * from './update-item.dto'; +export * from './item-query.dto'; diff --git a/apps/inventory/apps/backend/src/item/dto/item-query.dto.ts b/apps/inventory/apps/backend/src/item/dto/item-query.dto.ts new file mode 100644 index 000000000..50d74a5ea --- /dev/null +++ b/apps/inventory/apps/backend/src/item/dto/item-query.dto.ts @@ -0,0 +1,50 @@ +import { IsOptional, IsString, IsUUID, IsBoolean, IsIn, IsNumber, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class ItemQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsOptional() + @IsIn(['new', 'like_new', 'good', 'fair', 'poor']) + condition?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + isFavorite?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + isArchived?: boolean; + + @IsOptional() + @IsIn(['name', 'createdAt', 'updatedAt', 'purchaseDate', 'purchasePrice', 'currentValue']) + sortBy?: string; + + @IsOptional() + @IsIn(['asc', 'desc']) + sortOrder?: 'asc' | 'desc'; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number; +} diff --git a/apps/inventory/apps/backend/src/item/dto/update-item.dto.ts b/apps/inventory/apps/backend/src/item/dto/update-item.dto.ts new file mode 100644 index 000000000..74acfa928 --- /dev/null +++ b/apps/inventory/apps/backend/src/item/dto/update-item.dto.ts @@ -0,0 +1,76 @@ +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + IsUUID, + IsDateString, + IsIn, + Min, +} from 'class-validator'; +import type { ItemCondition } from '@inventory/shared'; + +export class UpdateItemDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsOptional() + @IsDateString() + purchaseDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + purchasePrice?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + @IsNumber() + @Min(0) + currentValue?: number; + + @IsOptional() + @IsIn(['new', 'like_new', 'good', 'fair', 'poor']) + condition?: ItemCondition; + + @IsOptional() + @IsDateString() + warrantyExpires?: string; + + @IsOptional() + @IsString() + warrantyNotes?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + @Min(0) + quantity?: number; + + @IsOptional() + @IsBoolean() + isFavorite?: boolean; +} diff --git a/apps/inventory/apps/backend/src/item/item.controller.ts b/apps/inventory/apps/backend/src/item/item.controller.ts new file mode 100644 index 000000000..bb1821d3a --- /dev/null +++ b/apps/inventory/apps/backend/src/item/item.controller.ts @@ -0,0 +1,60 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Patch, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ItemService } from './item.service'; +import { CreateItemDto, UpdateItemDto, ItemQueryDto } from './dto'; + +@Controller('api/v1/items') +@UseGuards(JwtAuthGuard) +export class ItemController { + constructor(private readonly itemService: ItemService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData, @Query() query: ItemQueryDto) { + return this.itemService.findAll(user.userId, query); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.itemService.findOne(user.userId, id); + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateItemDto) { + return this.itemService.create(user.userId, dto); + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateItemDto + ) { + return this.itemService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.itemService.delete(user.userId, id); + } + + @Patch(':id/toggle-favorite') + async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.itemService.toggleFavorite(user.userId, id); + } + + @Patch(':id/toggle-archive') + async toggleArchive(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.itemService.toggleArchive(user.userId, id); + } +} diff --git a/apps/inventory/apps/backend/src/item/item.module.ts b/apps/inventory/apps/backend/src/item/item.module.ts new file mode 100644 index 000000000..dfe23fc91 --- /dev/null +++ b/apps/inventory/apps/backend/src/item/item.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ItemController } from './item.controller'; +import { ItemService } from './item.service'; + +@Module({ + controllers: [ItemController], + providers: [ItemService], + exports: [ItemService], +}) +export class ItemModule {} diff --git a/apps/inventory/apps/backend/src/item/item.service.ts b/apps/inventory/apps/backend/src/item/item.service.ts new file mode 100644 index 000000000..010f5f30f --- /dev/null +++ b/apps/inventory/apps/backend/src/item/item.service.ts @@ -0,0 +1,251 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, ilike, desc, asc, or, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { items, categories, locations, itemPhotos, itemDocuments } from '../db/schema'; +import { CreateItemDto, UpdateItemDto, ItemQueryDto } from './dto'; + +@Injectable() +export class ItemService { + constructor(@Inject(DATABASE_CONNECTION) private db: DbClient) {} + + async findAll(userId: string, query: ItemQueryDto) { + const { + search, + categoryId, + locationId, + condition, + isFavorite, + isArchived = false, + sortBy = 'createdAt', + sortOrder = 'desc', + page = 1, + limit = 20, + } = query; + + const conditions = [eq(items.userId, userId), eq(items.isArchived, isArchived)]; + + if (search) { + const searchCondition = or( + ilike(items.name, `%${search}%`), + ilike(items.description, `%${search}%`) + ); + if (searchCondition) conditions.push(searchCondition); + } + if (categoryId) conditions.push(eq(items.categoryId, categoryId)); + if (locationId) conditions.push(eq(items.locationId, locationId)); + if (condition) conditions.push(eq(items.condition, condition)); + if (isFavorite !== undefined) conditions.push(eq(items.isFavorite, isFavorite)); + + const orderByMap = { + name: items.name, + createdAt: items.createdAt, + updatedAt: items.updatedAt, + purchaseDate: items.purchaseDate, + purchasePrice: items.purchasePrice, + currentValue: items.currentValue, + } as const; + + const orderByColumn = orderByMap[sortBy as keyof typeof orderByMap] || items.createdAt; + const orderFn = sortOrder === 'asc' ? asc : desc; + + const offset = (page - 1) * limit; + + const [data, countResult] = await Promise.all([ + this.db + .select({ + item: items, + category: categories, + location: locations, + }) + .from(items) + .leftJoin(categories, eq(items.categoryId, categories.id)) + .leftJoin(locations, eq(items.locationId, locations.id)) + .where(and(...conditions)) + .orderBy(orderFn(orderByColumn)) + .limit(limit) + .offset(offset), + this.db + .select({ count: sql`count(*)` }) + .from(items) + .where(and(...conditions)), + ]); + + const total = Number(countResult[0]?.count || 0); + + return { + items: data.map(({ item, category, location }) => ({ + ...item, + category, + location, + })), + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async findOne(userId: string, id: string) { + const result = await this.db + .select({ + item: items, + category: categories, + location: locations, + }) + .from(items) + .leftJoin(categories, eq(items.categoryId, categories.id)) + .leftJoin(locations, eq(items.locationId, locations.id)) + .where(and(eq(items.id, id), eq(items.userId, userId))) + .limit(1); + + if (!result.length) { + throw new NotFoundException('Item not found'); + } + + const { item, category, location } = result[0]; + + const [photos, documents] = await Promise.all([ + this.db + .select() + .from(itemPhotos) + .where(eq(itemPhotos.itemId, id)) + .orderBy(asc(itemPhotos.sortOrder)), + this.db + .select() + .from(itemDocuments) + .where(eq(itemDocuments.itemId, id)) + .orderBy(desc(itemDocuments.uploadedAt)), + ]); + + return { + ...item, + category, + location, + photos, + documents, + }; + } + + async create(userId: string, dto: CreateItemDto) { + const [item] = await this.db + .insert(items) + .values({ + userId, + name: dto.name, + description: dto.description, + sku: dto.sku, + categoryId: dto.categoryId, + locationId: dto.locationId, + purchaseDate: dto.purchaseDate, + purchasePrice: dto.purchasePrice?.toString(), + currency: dto.currency || 'EUR', + currentValue: dto.currentValue?.toString(), + condition: dto.condition || 'good', + warrantyExpires: dto.warrantyExpires, + warrantyNotes: dto.warrantyNotes, + notes: dto.notes, + quantity: dto.quantity?.toString() || '1', + isFavorite: dto.isFavorite || false, + }) + .returning(); + + return item; + } + + async update(userId: string, id: string, dto: UpdateItemDto) { + const existing = await this.db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Item not found'); + } + + const updateData: Record = { updatedAt: new Date() }; + if (dto.name !== undefined) updateData.name = dto.name; + if (dto.description !== undefined) updateData.description = dto.description; + if (dto.sku !== undefined) updateData.sku = dto.sku; + if (dto.categoryId !== undefined) updateData.categoryId = dto.categoryId; + if (dto.locationId !== undefined) updateData.locationId = dto.locationId; + if (dto.purchaseDate !== undefined) updateData.purchaseDate = dto.purchaseDate; + if (dto.purchasePrice !== undefined) updateData.purchasePrice = dto.purchasePrice?.toString(); + if (dto.currency !== undefined) updateData.currency = dto.currency; + if (dto.currentValue !== undefined) updateData.currentValue = dto.currentValue?.toString(); + if (dto.condition !== undefined) updateData.condition = dto.condition; + if (dto.warrantyExpires !== undefined) updateData.warrantyExpires = dto.warrantyExpires; + if (dto.warrantyNotes !== undefined) updateData.warrantyNotes = dto.warrantyNotes; + if (dto.notes !== undefined) updateData.notes = dto.notes; + if (dto.quantity !== undefined) updateData.quantity = dto.quantity?.toString(); + if (dto.isFavorite !== undefined) updateData.isFavorite = dto.isFavorite; + + const [item] = await this.db.update(items).set(updateData).where(eq(items.id, id)).returning(); + + return item; + } + + async delete(userId: string, id: string) { + const existing = await this.db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Item not found'); + } + + await this.db.delete(items).where(eq(items.id, id)); + return { success: true }; + } + + async toggleFavorite(userId: string, id: string) { + const existing = await this.db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Item not found'); + } + + const [item] = await this.db + .update(items) + .set({ + isFavorite: !existing[0].isFavorite, + updatedAt: new Date(), + }) + .where(eq(items.id, id)) + .returning(); + + return item; + } + + async toggleArchive(userId: string, id: string) { + const existing = await this.db + .select() + .from(items) + .where(and(eq(items.id, id), eq(items.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Item not found'); + } + + const [item] = await this.db + .update(items) + .set({ + isArchived: !existing[0].isArchived, + updatedAt: new Date(), + }) + .where(eq(items.id, id)) + .returning(); + + return item; + } +} diff --git a/apps/inventory/apps/backend/src/location/dto/location.dto.ts b/apps/inventory/apps/backend/src/location/dto/location.dto.ts new file mode 100644 index 000000000..609b0304c --- /dev/null +++ b/apps/inventory/apps/backend/src/location/dto/location.dto.ts @@ -0,0 +1,30 @@ +import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator'; + +export class CreateLocationDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentLocationId?: string; +} + +export class UpdateLocationDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentLocationId?: string; +} diff --git a/apps/inventory/apps/backend/src/location/location.controller.ts b/apps/inventory/apps/backend/src/location/location.controller.ts new file mode 100644 index 000000000..d6c77d55d --- /dev/null +++ b/apps/inventory/apps/backend/src/location/location.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { LocationService } from './location.service'; +import { CreateLocationDto, UpdateLocationDto } from './dto/location.dto'; + +@Controller('api/v1/locations') +@UseGuards(JwtAuthGuard) +export class LocationController { + constructor(private readonly locationService: LocationService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + return this.locationService.findAll(user.userId); + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.locationService.findOne(user.userId, id); + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) { + return this.locationService.create(user.userId, dto); + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateLocationDto + ) { + return this.locationService.update(user.userId, id, dto); + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.locationService.delete(user.userId, id); + } +} diff --git a/apps/inventory/apps/backend/src/location/location.module.ts b/apps/inventory/apps/backend/src/location/location.module.ts new file mode 100644 index 000000000..e285ce9a6 --- /dev/null +++ b/apps/inventory/apps/backend/src/location/location.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LocationController } from './location.controller'; +import { LocationService } from './location.service'; + +@Module({ + controllers: [LocationController], + providers: [LocationService], + exports: [LocationService], +}) +export class LocationModule {} diff --git a/apps/inventory/apps/backend/src/location/location.service.ts b/apps/inventory/apps/backend/src/location/location.service.ts new file mode 100644 index 000000000..aa71e6ee3 --- /dev/null +++ b/apps/inventory/apps/backend/src/location/location.service.ts @@ -0,0 +1,153 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { locations, items } from '../db/schema'; +import { CreateLocationDto, UpdateLocationDto } from './dto/location.dto'; + +export interface LocationWithChildren { + id: string; + userId: string; + name: string; + description: string | null; + parentLocationId: string | null; + createdAt: Date; + children: LocationWithChildren[]; +} + +@Injectable() +export class LocationService { + constructor(@Inject(DATABASE_CONNECTION) private db: DbClient) {} + + async findAll(userId: string) { + const allLocations = await this.db.select().from(locations).where(eq(locations.userId, userId)); + + return this.buildTree(allLocations); + } + + async findOne(userId: string, id: string) { + const result = await this.db + .select() + .from(locations) + .where(and(eq(locations.id, id), eq(locations.userId, userId))) + .limit(1); + + if (!result.length) { + throw new NotFoundException('Location not found'); + } + + return result[0]; + } + + async create(userId: string, dto: CreateLocationDto) { + if (dto.parentLocationId) { + const parent = await this.db + .select() + .from(locations) + .where(and(eq(locations.id, dto.parentLocationId), eq(locations.userId, userId))) + .limit(1); + + if (!parent.length) { + throw new BadRequestException('Parent location not found'); + } + } + + const [location] = await this.db + .insert(locations) + .values({ + userId, + name: dto.name, + description: dto.description, + parentLocationId: dto.parentLocationId, + }) + .returning(); + + return location; + } + + async update(userId: string, id: string, dto: UpdateLocationDto) { + const existing = await this.db + .select() + .from(locations) + .where(and(eq(locations.id, id), eq(locations.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Location not found'); + } + + if (dto.parentLocationId) { + if (dto.parentLocationId === id) { + throw new BadRequestException('Location cannot be its own parent'); + } + + const parent = await this.db + .select() + .from(locations) + .where(and(eq(locations.id, dto.parentLocationId), eq(locations.userId, userId))) + .limit(1); + + if (!parent.length) { + throw new BadRequestException('Parent location not found'); + } + } + + const [location] = await this.db + .update(locations) + .set({ + name: dto.name ?? existing[0].name, + description: dto.description ?? existing[0].description, + parentLocationId: dto.parentLocationId ?? existing[0].parentLocationId, + }) + .where(eq(locations.id, id)) + .returning(); + + return location; + } + + async delete(userId: string, id: string) { + const existing = await this.db + .select() + .from(locations) + .where(and(eq(locations.id, id), eq(locations.userId, userId))) + .limit(1); + + if (!existing.length) { + throw new NotFoundException('Location not found'); + } + + await this.db + .update(locations) + .set({ parentLocationId: null }) + .where(eq(locations.parentLocationId, id)); + await this.db.update(items).set({ locationId: null }).where(eq(items.locationId, id)); + await this.db.delete(locations).where(eq(locations.id, id)); + + return { success: true }; + } + + private buildTree(allLocations: (typeof locations.$inferSelect)[]): LocationWithChildren[] { + const map = new Map(); + const roots: LocationWithChildren[] = []; + + for (const loc of allLocations) { + map.set(loc.id, { ...loc, children: [] }); + } + + for (const loc of allLocations) { + const node = map.get(loc.id)!; + if (loc.parentLocationId) { + const parent = map.get(loc.parentLocationId); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } else { + roots.push(node); + } + } + + return roots; + } +} diff --git a/apps/inventory/apps/backend/src/main.ts b/apps/inventory/apps/backend/src/main.ts new file mode 100644 index 000000000..51918417c --- /dev/null +++ b/apps/inventory/apps/backend/src/main.ts @@ -0,0 +1,37 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('PORT') || 3020; + const corsOrigins = + configService.get('CORS_ORIGINS') || + 'http://localhost:5173,http://localhost:5188,http://localhost:8081'; + + app.enableCors({ + origin: corsOrigins.split(',').map((o) => o.trim()), + credentials: true, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }) + ); + + await app.listen(port); + console.log(`[Inventory Backend] Running on http://localhost:${port}`); +} + +bootstrap(); diff --git a/apps/inventory/apps/backend/src/photo/photo.controller.ts b/apps/inventory/apps/backend/src/photo/photo.controller.ts new file mode 100644 index 000000000..2c14c24f4 --- /dev/null +++ b/apps/inventory/apps/backend/src/photo/photo.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Post, + Delete, + Patch, + Body, + Param, + UseGuards, + UseInterceptors, + UploadedFiles, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { PhotoService } from './photo.service'; + +@Controller('api/v1/items/:itemId/photos') +@UseGuards(JwtAuthGuard) +export class PhotoController { + constructor(private readonly photoService: PhotoService) {} + + @Post() + @UseInterceptors(FilesInterceptor('photos', 10, { limits: { fileSize: 10 * 1024 * 1024 } })) + async uploadPhotos( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @UploadedFiles() files: Express.Multer.File[] + ) { + return this.photoService.uploadPhotos(user.userId, itemId, files); + } + + @Delete(':photoId') + async deletePhoto( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @Param('photoId') photoId: string + ) { + return this.photoService.deletePhoto(user.userId, itemId, photoId); + } + + @Patch(':photoId/set-primary') + async setPrimary( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @Param('photoId') photoId: string + ) { + return this.photoService.setPrimary(user.userId, itemId, photoId); + } + + @Patch('reorder') + async reorderPhotos( + @CurrentUser() user: CurrentUserData, + @Param('itemId') itemId: string, + @Body('photoIds') photoIds: string[] + ) { + return this.photoService.reorderPhotos(user.userId, itemId, photoIds); + } +} diff --git a/apps/inventory/apps/backend/src/photo/photo.module.ts b/apps/inventory/apps/backend/src/photo/photo.module.ts new file mode 100644 index 000000000..c7bc2175f --- /dev/null +++ b/apps/inventory/apps/backend/src/photo/photo.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { PhotoController } from './photo.controller'; +import { PhotoService } from './photo.service'; + +@Module({ + imports: [ + MulterModule.register({ + limits: { fileSize: 10 * 1024 * 1024 }, + }), + ], + controllers: [PhotoController], + providers: [PhotoService], + exports: [PhotoService], +}) +export class PhotoModule {} diff --git a/apps/inventory/apps/backend/src/photo/photo.service.ts b/apps/inventory/apps/backend/src/photo/photo.service.ts new file mode 100644 index 000000000..5c4fe2c6f --- /dev/null +++ b/apps/inventory/apps/backend/src/photo/photo.service.ts @@ -0,0 +1,152 @@ +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; +import { eq, and, asc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { DbClient } from '../db/connection'; +import { items, itemPhotos } from '../db/schema'; +import { StorageService } from '../storage/storage.service'; + +@Injectable() +export class PhotoService { + constructor( + @Inject(DATABASE_CONNECTION) private db: DbClient, + private storageService: StorageService + ) {} + + async uploadPhotos(userId: string, itemId: string, files: Express.Multer.File[]) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + const existingPhotos = await this.db + .select() + .from(itemPhotos) + .where(eq(itemPhotos.itemId, itemId)); + + const isPrimaryAvailable = !existingPhotos.some((p) => p.isPrimary); + const startOrder = existingPhotos.length; + + const uploadedPhotos: Array = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + if (!file.mimetype.startsWith('image/')) { + throw new BadRequestException(`File ${file.originalname} is not an image`); + } + + const { key, url } = await this.storageService.uploadPhoto(userId, file); + + const [photo] = await this.db + .insert(itemPhotos) + .values({ + itemId, + storageKey: key, + isPrimary: isPrimaryAvailable && i === 0, + sortOrder: startOrder + i, + }) + .returning(); + + uploadedPhotos.push({ ...photo, url }); + } + + return uploadedPhotos; + } + + async deletePhoto(userId: string, itemId: string, photoId: string) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + const photo = await this.db + .select() + .from(itemPhotos) + .where(and(eq(itemPhotos.id, photoId), eq(itemPhotos.itemId, itemId))) + .limit(1); + + if (!photo.length) { + throw new NotFoundException('Photo not found'); + } + + await this.storageService.deleteFile(photo[0].storageKey); + await this.db.delete(itemPhotos).where(eq(itemPhotos.id, photoId)); + + if (photo[0].isPrimary) { + const remainingPhotos = await this.db + .select() + .from(itemPhotos) + .where(eq(itemPhotos.itemId, itemId)) + .orderBy(asc(itemPhotos.sortOrder)) + .limit(1); + + if (remainingPhotos.length) { + await this.db + .update(itemPhotos) + .set({ isPrimary: true }) + .where(eq(itemPhotos.id, remainingPhotos[0].id)); + } + } + + return { success: true }; + } + + async setPrimary(userId: string, itemId: string, photoId: string) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + const photo = await this.db + .select() + .from(itemPhotos) + .where(and(eq(itemPhotos.id, photoId), eq(itemPhotos.itemId, itemId))) + .limit(1); + + if (!photo.length) { + throw new NotFoundException('Photo not found'); + } + + await this.db.update(itemPhotos).set({ isPrimary: false }).where(eq(itemPhotos.itemId, itemId)); + const [updatedPhoto] = await this.db + .update(itemPhotos) + .set({ isPrimary: true }) + .where(eq(itemPhotos.id, photoId)) + .returning(); + + return updatedPhoto; + } + + async reorderPhotos(userId: string, itemId: string, photoIds: string[]) { + const item = await this.db + .select() + .from(items) + .where(and(eq(items.id, itemId), eq(items.userId, userId))) + .limit(1); + + if (!item.length) { + throw new NotFoundException('Item not found'); + } + + for (let i = 0; i < photoIds.length; i++) { + await this.db.update(itemPhotos).set({ sortOrder: i }).where(eq(itemPhotos.id, photoIds[i])); + } + + return { success: true }; + } +} diff --git a/apps/inventory/apps/backend/src/storage/storage.module.ts b/apps/inventory/apps/backend/src/storage/storage.module.ts new file mode 100644 index 000000000..0825876c4 --- /dev/null +++ b/apps/inventory/apps/backend/src/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/inventory/apps/backend/src/storage/storage.service.ts b/apps/inventory/apps/backend/src/storage/storage.service.ts new file mode 100644 index 000000000..9ac894e6b --- /dev/null +++ b/apps/inventory/apps/backend/src/storage/storage.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { + createInventoryStorage, + generateUserFileKey, + getContentType, +} from '@manacore/shared-storage'; +import type { StorageClient } from '@manacore/shared-storage'; + +@Injectable() +export class StorageService { + private storage: StorageClient; + + constructor() { + this.storage = createInventoryStorage(); + } + + async uploadPhoto( + userId: string, + file: Express.Multer.File + ): Promise<{ key: string; url: string }> { + const key = generateUserFileKey(userId, `photos/${Date.now()}-${file.originalname}`); + const result = await this.storage.upload(key, file.buffer, { + contentType: file.mimetype, + public: true, + }); + return { key, url: result.url || this.getPublicUrl(key) }; + } + + async uploadDocument( + userId: string, + file: Express.Multer.File + ): Promise<{ key: string; url: string }> { + const key = generateUserFileKey(userId, `documents/${Date.now()}-${file.originalname}`); + const result = await this.storage.upload(key, file.buffer, { + contentType: file.mimetype, + public: false, + }); + return { key, url: result.url || '' }; + } + + async deleteFile(key: string): Promise { + await this.storage.delete(key); + } + + async getDownloadUrl(key: string, expiresIn = 3600): Promise { + return this.storage.getDownloadUrl(key, { expiresIn }); + } + + getPublicUrl(key: string): string { + const publicUrl = process.env.INVENTORY_S3_PUBLIC_URL || process.env.S3_ENDPOINT; + return `${publicUrl}/${key}`; + } +} diff --git a/apps/inventory/apps/backend/tsconfig.json b/apps/inventory/apps/backend/tsconfig.json new file mode 100644 index 000000000..8624b042e --- /dev/null +++ b/apps/inventory/apps/backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/inventory/apps/landing/astro.config.mjs b/apps/inventory/apps/landing/astro.config.mjs new file mode 100644 index 000000000..291f517cb --- /dev/null +++ b/apps/inventory/apps/landing/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [tailwind()], + server: { + port: 4325, + }, +}); diff --git a/apps/inventory/apps/landing/package.json b/apps/inventory/apps/landing/package.json new file mode 100644 index 000000000..671bca035 --- /dev/null +++ b/apps/inventory/apps/landing/package.json @@ -0,0 +1,22 @@ +{ + "name": "@inventory/landing", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev --port 4325", + "build": "astro build", + "preview": "astro preview --port 4325", + "type-check": "astro check" + }, + "dependencies": { + "astro": "^5.1.1", + "@manacore/shared-branding": "workspace:*" + }, + "devDependencies": { + "@astrojs/tailwind": "^6.0.0", + "@types/node": "^22.10.2", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2" + } +} diff --git a/apps/inventory/apps/landing/public/favicon.svg b/apps/inventory/apps/landing/public/favicon.svg new file mode 100644 index 000000000..524ab8291 --- /dev/null +++ b/apps/inventory/apps/landing/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/inventory/apps/landing/src/layouts/Layout.astro b/apps/inventory/apps/landing/src/layouts/Layout.astro new file mode 100644 index 000000000..75e8a0133 --- /dev/null +++ b/apps/inventory/apps/landing/src/layouts/Layout.astro @@ -0,0 +1,34 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} + + + + + + + diff --git a/apps/inventory/apps/landing/src/pages/index.astro b/apps/inventory/apps/landing/src/pages/index.astro new file mode 100644 index 000000000..1ea27ed4e --- /dev/null +++ b/apps/inventory/apps/landing/src/pages/index.astro @@ -0,0 +1,263 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + + +
+
+
+
+ + + +
+

+ Behalte den Überblick +

+

+ Verwalte deinen Besitz digital. Fotos, Kaufbelege, Garantiescheine - alles an einem Ort. + Wisse immer, was du hast und wo es ist. +

+ +
+
+
+ + +
+
+
+

+ Alles was du brauchst +

+

Eine App für dein gesamtes Inventar

+
+ +
+ +
+
+ + + +
+

Fotos & Dokumente

+

+ Füge Fotos zu jedem Gegenstand hinzu. Speichere Kaufbelege, Garantiescheine und + Handbücher digital. +

+
+ + +
+
+ + + +
+

Kategorien & Standorte

+

+ Organisiere mit hierarchischen Kategorien und Standorten. Finde alles schnell wieder. +

+
+ + +
+
+ + + +
+

Garantie-Tracking

+

+ Behalte Garantie-Ablaufdaten im Blick. Nie wieder eine Garantie verpassen. +

+
+ + +
+
+ + + +
+

Wert-Übersicht

+

+ Behalte Kaufpreise und aktuelle Werte im Blick. Perfekt für Versicherungszwecke. +

+
+ + +
+
+ + + +
+

Import & Export

+

+ Importiere bestehende Listen per CSV. Exportiere deine Daten jederzeit. +

+
+ + +
+
+ + + +
+

Schnelle Suche

+

+ Finde jeden Gegenstand in Sekunden. Filter nach Kategorie, Standort oder Zustand. +

+
+
+
+
+ + +
+
+

+ Bereit für mehr Ordnung? +

+

+ Starte jetzt kostenlos und behalte den Überblick über deinen Besitz. +

+ + Kostenlos registrieren + +
+
+ + +
+
+
+
+
+ + + +
+ Inventory +
+

+ Ein Produkt von Mana Core +

+
+
+
+
diff --git a/apps/inventory/apps/landing/tailwind.config.mjs b/apps/inventory/apps/landing/tailwind.config.mjs new file mode 100644 index 000000000..a2f44d274 --- /dev/null +++ b/apps/inventory/apps/landing/tailwind.config.mjs @@ -0,0 +1,23 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: { + colors: { + primary: { + 50: '#f0fdfa', + 100: '#ccfbf1', + 200: '#99f6e4', + 300: '#5eead4', + 400: '#2dd4bf', + 500: '#14b8a6', + 600: '#0d9488', + 700: '#0f766e', + 800: '#115e59', + 900: '#134e4a', + }, + }, + }, + }, + plugins: [], +}; diff --git a/apps/inventory/apps/landing/tsconfig.json b/apps/inventory/apps/landing/tsconfig.json new file mode 100644 index 000000000..d9133c415 --- /dev/null +++ b/apps/inventory/apps/landing/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/apps/inventory/apps/landing/wrangler.toml b/apps/inventory/apps/landing/wrangler.toml new file mode 100644 index 000000000..c216d3467 --- /dev/null +++ b/apps/inventory/apps/landing/wrangler.toml @@ -0,0 +1,3 @@ +name = "inventory-landing" +compatibility_date = "2024-12-01" +pages_build_output_dir = "dist" diff --git a/apps/inventory/apps/web/package.json b/apps/inventory/apps/web/package.json new file mode 100644 index 000000000..ccf5a4270 --- /dev/null +++ b/apps/inventory/apps/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "@inventory/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev --port 5188", + "build": "vite build", + "preview": "vite preview --port 5188", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@inventory/shared": "workspace:*", + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-feedback-service": "workspace:*", + "@manacore/shared-feedback-ui": "workspace:*", + "@manacore/shared-i18n": "workspace:*", + "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "svelte-i18n": "^4.0.1" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^4.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^22.10.2", + "svelte": "^5.16.0", + "svelte-check": "^4.1.1", + "tailwindcss": "^4.1.7", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "type": "module" +} diff --git a/apps/inventory/apps/web/src/app.css b/apps/inventory/apps/web/src/app.css new file mode 100644 index 000000000..3bcb6e256 --- /dev/null +++ b/apps/inventory/apps/web/src/app.css @@ -0,0 +1,37 @@ +@import "tailwindcss"; +@import "@manacore/shared-tailwind/themes.css"; + +/* Inventory-specific CSS Variables */ +@layer base { + :root { + /* Spacing */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + } +} + +html, +body { + height: 100%; +} + +body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); +} diff --git a/apps/inventory/apps/web/src/app.html b/apps/inventory/apps/web/src/app.html new file mode 100644 index 000000000..bcb2c5b4e --- /dev/null +++ b/apps/inventory/apps/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/inventory/apps/web/src/lib/api/categories.ts b/apps/inventory/apps/web/src/lib/api/categories.ts new file mode 100644 index 000000000..7624c6e58 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/api/categories.ts @@ -0,0 +1,51 @@ +import { apiRequest } from './client'; +import type { + Category, + CategoryWithChildren, + CreateCategoryInput, + UpdateCategoryInput, +} from '@inventory/shared'; + +export type { CategoryWithChildren }; + +export const categoriesApi = { + async getAll(token?: string): Promise { + return apiRequest('/api/v1/categories', {}, token); + }, + + async getOne(id: string, token?: string): Promise { + return apiRequest(`/api/v1/categories/${id}`, {}, token); + }, + + async create(data: CreateCategoryInput, token?: string): Promise { + return apiRequest( + '/api/v1/categories', + { + method: 'POST', + body: JSON.stringify(data), + }, + token + ); + }, + + async update(id: string, data: UpdateCategoryInput, token?: string): Promise { + return apiRequest( + `/api/v1/categories/${id}`, + { + method: 'PATCH', + body: JSON.stringify(data), + }, + token + ); + }, + + async delete(id: string, token?: string): Promise<{ success: boolean }> { + return apiRequest( + `/api/v1/categories/${id}`, + { + method: 'DELETE', + }, + token + ); + }, +}; diff --git a/apps/inventory/apps/web/src/lib/api/client.ts b/apps/inventory/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..252c3fe26 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/api/client.ts @@ -0,0 +1,57 @@ +const API_URL = import.meta.env.PUBLIC_BACKEND_URL || 'http://localhost:3018'; + +export async function apiRequest( + endpoint: string, + options: RequestInit = {}, + token?: string +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +export async function apiUpload( + endpoint: string, + formData: FormData, + token?: string +): Promise { + const headers: Record = {}; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +export function getDownloadUrl(endpoint: string): string { + return `${API_URL}${endpoint}`; +} diff --git a/apps/inventory/apps/web/src/lib/api/index.ts b/apps/inventory/apps/web/src/lib/api/index.ts new file mode 100644 index 000000000..9e06fd0d5 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/api/index.ts @@ -0,0 +1,4 @@ +export { apiRequest, apiUpload, getDownloadUrl } from './client'; +export { itemsApi } from './items'; +export { categoriesApi, type CategoryWithChildren } from './categories'; +export { locationsApi, type LocationWithChildren } from './locations'; diff --git a/apps/inventory/apps/web/src/lib/api/items.ts b/apps/inventory/apps/web/src/lib/api/items.ts new file mode 100644 index 000000000..60bc5d45e --- /dev/null +++ b/apps/inventory/apps/web/src/lib/api/items.ts @@ -0,0 +1,144 @@ +import { apiRequest, apiUpload } from './client'; +import type { + Item, + ItemPhoto, + ItemDocument, + CreateItemInput, + UpdateItemInput, + ItemQueryParams, + PaginatedResponse, +} from '@inventory/shared'; + +export const itemsApi = { + async getAll(params: ItemQueryParams = {}, token?: string): Promise> { + const searchParams = new URLSearchParams(); + if (params.search) searchParams.set('search', params.search); + if (params.categoryId) searchParams.set('categoryId', params.categoryId); + if (params.locationId) searchParams.set('locationId', params.locationId); + if (params.condition) searchParams.set('condition', params.condition); + if (params.isFavorite !== undefined) searchParams.set('isFavorite', String(params.isFavorite)); + if (params.isArchived !== undefined) searchParams.set('isArchived', String(params.isArchived)); + if (params.sortBy) searchParams.set('sortBy', params.sortBy); + if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder); + if (params.page) searchParams.set('page', String(params.page)); + if (params.limit) searchParams.set('limit', String(params.limit)); + + const query = searchParams.toString(); + return apiRequest(`/api/v1/items${query ? `?${query}` : ''}`, {}, token); + }, + + async getOne( + id: string, + token?: string + ): Promise { + return apiRequest(`/api/v1/items/${id}`, {}, token); + }, + + async create(data: CreateItemInput, token?: string): Promise { + return apiRequest( + '/api/v1/items', + { + method: 'POST', + body: JSON.stringify(data), + }, + token + ); + }, + + async update(id: string, data: UpdateItemInput, token?: string): Promise { + return apiRequest( + `/api/v1/items/${id}`, + { + method: 'PUT', + body: JSON.stringify(data), + }, + token + ); + }, + + async delete(id: string, token?: string): Promise<{ success: boolean }> { + return apiRequest( + `/api/v1/items/${id}`, + { + method: 'DELETE', + }, + token + ); + }, + + async toggleFavorite(id: string, token?: string): Promise { + return apiRequest( + `/api/v1/items/${id}/toggle-favorite`, + { + method: 'PATCH', + }, + token + ); + }, + + async toggleArchive(id: string, token?: string): Promise { + return apiRequest( + `/api/v1/items/${id}/toggle-archive`, + { + method: 'PATCH', + }, + token + ); + }, + + async uploadPhotos(itemId: string, files: File[], token?: string): Promise { + const formData = new FormData(); + files.forEach((file) => formData.append('photos', file)); + return apiUpload(`/api/v1/items/${itemId}/photos`, formData, token); + }, + + async deletePhoto( + itemId: string, + photoId: string, + token?: string + ): Promise<{ success: boolean }> { + return apiRequest( + `/api/v1/items/${itemId}/photos/${photoId}`, + { + method: 'DELETE', + }, + token + ); + }, + + async setPrimaryPhoto(itemId: string, photoId: string, token?: string): Promise { + return apiRequest( + `/api/v1/items/${itemId}/photos/${photoId}/set-primary`, + { + method: 'PATCH', + }, + token + ); + }, + + async uploadDocument( + itemId: string, + file: File, + documentType: string, + token?: string + ): Promise { + const formData = new FormData(); + formData.append('document', file); + formData.append('documentType', documentType); + return apiUpload(`/api/v1/items/${itemId}/documents`, formData, token); + }, + + async deleteDocument( + itemId: string, + documentId: string, + token?: string + ): Promise<{ success: boolean }> { + return apiRequest( + `/api/v1/items/${itemId}/documents/${documentId}`, + { + method: 'DELETE', + }, + token + ); + }, +}; diff --git a/apps/inventory/apps/web/src/lib/api/locations.ts b/apps/inventory/apps/web/src/lib/api/locations.ts new file mode 100644 index 000000000..3c4d7d73b --- /dev/null +++ b/apps/inventory/apps/web/src/lib/api/locations.ts @@ -0,0 +1,51 @@ +import { apiRequest } from './client'; +import type { + Location, + LocationWithChildren, + CreateLocationInput, + UpdateLocationInput, +} from '@inventory/shared'; + +export type { LocationWithChildren }; + +export const locationsApi = { + async getAll(token?: string): Promise { + return apiRequest('/api/v1/locations', {}, token); + }, + + async getOne(id: string, token?: string): Promise { + return apiRequest(`/api/v1/locations/${id}`, {}, token); + }, + + async create(data: CreateLocationInput, token?: string): Promise { + return apiRequest( + '/api/v1/locations', + { + method: 'POST', + body: JSON.stringify(data), + }, + token + ); + }, + + async update(id: string, data: UpdateLocationInput, token?: string): Promise { + return apiRequest( + `/api/v1/locations/${id}`, + { + method: 'PATCH', + body: JSON.stringify(data), + }, + token + ); + }, + + async delete(id: string, token?: string): Promise<{ success: boolean }> { + return apiRequest( + `/api/v1/locations/${id}`, + { + method: 'DELETE', + }, + token + ); + }, +}; diff --git a/apps/inventory/apps/web/src/lib/components/AppSlider.svelte b/apps/inventory/apps/web/src/lib/components/AppSlider.svelte new file mode 100644 index 000000000..ac8bb846d --- /dev/null +++ b/apps/inventory/apps/web/src/lib/components/AppSlider.svelte @@ -0,0 +1,32 @@ + + + diff --git a/apps/inventory/apps/web/src/lib/components/LanguageSelector.svelte b/apps/inventory/apps/web/src/lib/components/LanguageSelector.svelte new file mode 100644 index 000000000..4422634ee --- /dev/null +++ b/apps/inventory/apps/web/src/lib/components/LanguageSelector.svelte @@ -0,0 +1,45 @@ + + +
+ + + {#if isOpen} +
+ {#each supportedLocales as lang} + + {/each} +
+ {/if} +
diff --git a/apps/inventory/apps/web/src/lib/i18n/index.ts b/apps/inventory/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..a2549e2df --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/index.ts @@ -0,0 +1,27 @@ +import { init, register, getLocaleFromNavigator, locale } from 'svelte-i18n'; + +export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const; +export type SupportedLocale = (typeof supportedLocales)[number]; + +register('de', () => import('./locales/de.json')); +register('en', () => import('./locales/en.json')); +register('fr', () => import('./locales/fr.json')); +register('es', () => import('./locales/es.json')); +register('it', () => import('./locales/it.json')); + +const storedLocale = + typeof localStorage !== 'undefined' ? localStorage.getItem('inventory-locale') : null; + +init({ + fallbackLocale: 'de', + initialLocale: storedLocale || getLocaleFromNavigator() || 'de', +}); + +export function setLocale(newLocale: SupportedLocale) { + locale.set(newLocale); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('inventory-locale', newLocale); + } +} + +export { locale }; diff --git a/apps/inventory/apps/web/src/lib/i18n/locales/de.json b/apps/inventory/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..ce290dccc --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/locales/de.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Inventory", + "tagline": "Verwalte dein Inventar" + }, + "nav": { + "items": "Gegenstände", + "favorites": "Favoriten", + "archive": "Archiv", + "categories": "Kategorien", + "locations": "Standorte", + "import": "Importieren", + "settings": "Einstellungen", + "feedback": "Feedback", + "mana": "Mana" + }, + "items": { + "title": "Meine Gegenstände", + "new": "Neuer Gegenstand", + "edit": "Bearbeiten", + "delete": "Löschen", + "search": "Suchen...", + "empty": "Keine Gegenstände gefunden", + "emptyCreate": "Erstelle deinen ersten Gegenstand", + "confirmDelete": "Möchtest du diesen Gegenstand wirklich löschen?", + "details": "Details", + "photos": "Fotos", + "documents": "Dokumente" + }, + "item": { + "name": "Name", + "description": "Beschreibung", + "sku": "Artikelnummer", + "category": "Kategorie", + "location": "Standort", + "purchaseDate": "Kaufdatum", + "purchasePrice": "Kaufpreis", + "currency": "Währung", + "currentValue": "Aktueller Wert", + "condition": "Zustand", + "warrantyExpires": "Garantie bis", + "warrantyNotes": "Garantie-Hinweise", + "notes": "Notizen", + "quantity": "Menge", + "favorite": "Favorit", + "archive": "Archivieren", + "unarchive": "Wiederherstellen" + }, + "conditions": { + "new": "Neu", + "like_new": "Wie neu", + "good": "Gut", + "fair": "Akzeptabel", + "poor": "Schlecht" + }, + "photos": { + "upload": "Fotos hochladen", + "setPrimary": "Als Hauptbild", + "delete": "Löschen", + "empty": "Keine Fotos vorhanden" + }, + "documents": { + "upload": "Dokument hochladen", + "download": "Herunterladen", + "delete": "Löschen", + "empty": "Keine Dokumente vorhanden", + "types": { + "receipt": "Kaufbeleg", + "warranty": "Garantieschein", + "manual": "Handbuch", + "other": "Sonstiges" + } + }, + "categories": { + "title": "Kategorien", + "new": "Neue Kategorie", + "edit": "Bearbeiten", + "delete": "Löschen", + "empty": "Keine Kategorien vorhanden", + "confirmDelete": "Möchtest du diese Kategorie wirklich löschen?", + "name": "Name", + "icon": "Icon", + "color": "Farbe", + "parent": "Übergeordnete Kategorie" + }, + "locations": { + "title": "Standorte", + "new": "Neuer Standort", + "edit": "Bearbeiten", + "delete": "Löschen", + "empty": "Keine Standorte vorhanden", + "confirmDelete": "Möchtest du diesen Standort wirklich löschen?", + "name": "Name", + "description": "Beschreibung", + "parent": "Übergeordneter Standort" + }, + "import": { + "title": "Importieren", + "csv": "CSV Import", + "selectFile": "Datei auswählen", + "template": "Vorlage herunterladen", + "success": "Import erfolgreich", + "imported": "{count} Gegenstände importiert" + }, + "favorites": { + "title": "Favoriten", + "empty": "Keine Favoriten vorhanden" + }, + "archive": { + "title": "Archiv", + "empty": "Keine archivierten Gegenstände" + }, + "auth": { + "login": "Anmelden", + "register": "Registrieren", + "logout": "Abmelden", + "forgotPassword": "Passwort vergessen?", + "email": "E-Mail", + "password": "Passwort", + "name": "Name" + }, + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "create": "Erstellen", + "edit": "Bearbeiten", + "delete": "Löschen", + "back": "Zurück", + "loading": "Laden...", + "error": "Ein Fehler ist aufgetreten", + "noResults": "Keine Ergebnisse", + "all": "Alle", + "none": "Keine" + } +} diff --git a/apps/inventory/apps/web/src/lib/i18n/locales/en.json b/apps/inventory/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..f61c042f8 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Inventory", + "tagline": "Manage your inventory" + }, + "nav": { + "items": "Items", + "favorites": "Favorites", + "archive": "Archive", + "categories": "Categories", + "locations": "Locations", + "import": "Import", + "settings": "Settings", + "feedback": "Feedback", + "mana": "Mana" + }, + "items": { + "title": "My Items", + "new": "New Item", + "edit": "Edit", + "delete": "Delete", + "search": "Search...", + "empty": "No items found", + "emptyCreate": "Create your first item", + "confirmDelete": "Are you sure you want to delete this item?", + "details": "Details", + "photos": "Photos", + "documents": "Documents" + }, + "item": { + "name": "Name", + "description": "Description", + "sku": "SKU", + "category": "Category", + "location": "Location", + "purchaseDate": "Purchase Date", + "purchasePrice": "Purchase Price", + "currency": "Currency", + "currentValue": "Current Value", + "condition": "Condition", + "warrantyExpires": "Warranty Until", + "warrantyNotes": "Warranty Notes", + "notes": "Notes", + "quantity": "Quantity", + "favorite": "Favorite", + "archive": "Archive", + "unarchive": "Restore" + }, + "conditions": { + "new": "New", + "like_new": "Like New", + "good": "Good", + "fair": "Fair", + "poor": "Poor" + }, + "photos": { + "upload": "Upload Photos", + "setPrimary": "Set as Primary", + "delete": "Delete", + "empty": "No photos yet" + }, + "documents": { + "upload": "Upload Document", + "download": "Download", + "delete": "Delete", + "empty": "No documents yet", + "types": { + "receipt": "Receipt", + "warranty": "Warranty", + "manual": "Manual", + "other": "Other" + } + }, + "categories": { + "title": "Categories", + "new": "New Category", + "edit": "Edit", + "delete": "Delete", + "empty": "No categories yet", + "confirmDelete": "Are you sure you want to delete this category?", + "name": "Name", + "icon": "Icon", + "color": "Color", + "parent": "Parent Category" + }, + "locations": { + "title": "Locations", + "new": "New Location", + "edit": "Edit", + "delete": "Delete", + "empty": "No locations yet", + "confirmDelete": "Are you sure you want to delete this location?", + "name": "Name", + "description": "Description", + "parent": "Parent Location" + }, + "import": { + "title": "Import", + "csv": "CSV Import", + "selectFile": "Select File", + "template": "Download Template", + "success": "Import successful", + "imported": "{count} items imported" + }, + "favorites": { + "title": "Favorites", + "empty": "No favorites yet" + }, + "archive": { + "title": "Archive", + "empty": "No archived items" + }, + "auth": { + "login": "Login", + "register": "Register", + "logout": "Logout", + "forgotPassword": "Forgot Password?", + "email": "Email", + "password": "Password", + "name": "Name" + }, + "common": { + "save": "Save", + "cancel": "Cancel", + "create": "Create", + "edit": "Edit", + "delete": "Delete", + "back": "Back", + "loading": "Loading...", + "error": "An error occurred", + "noResults": "No results", + "all": "All", + "none": "None" + } +} diff --git a/apps/inventory/apps/web/src/lib/i18n/locales/es.json b/apps/inventory/apps/web/src/lib/i18n/locales/es.json new file mode 100644 index 000000000..2e5020b88 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/locales/es.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Inventory", + "tagline": "Gestiona tu inventario" + }, + "nav": { + "items": "Objetos", + "favorites": "Favoritos", + "archive": "Archivo", + "categories": "Categorias", + "locations": "Ubicaciones", + "import": "Importar", + "settings": "Ajustes", + "feedback": "Feedback", + "mana": "Mana" + }, + "items": { + "title": "Mis objetos", + "new": "Nuevo objeto", + "edit": "Editar", + "delete": "Eliminar", + "search": "Buscar...", + "empty": "No se encontraron objetos", + "emptyCreate": "Crea tu primer objeto", + "confirmDelete": "Realmente deseas eliminar este objeto?", + "details": "Detalles", + "photos": "Fotos", + "documents": "Documentos" + }, + "item": { + "name": "Nombre", + "description": "Descripcion", + "sku": "Referencia", + "category": "Categoria", + "location": "Ubicacion", + "purchaseDate": "Fecha de compra", + "purchasePrice": "Precio de compra", + "currency": "Moneda", + "currentValue": "Valor actual", + "condition": "Estado", + "warrantyExpires": "Garantia hasta", + "warrantyNotes": "Notas de garantia", + "notes": "Notas", + "quantity": "Cantidad", + "favorite": "Favorito", + "archive": "Archivar", + "unarchive": "Restaurar" + }, + "conditions": { + "new": "Nuevo", + "like_new": "Como nuevo", + "good": "Bueno", + "fair": "Aceptable", + "poor": "Malo" + }, + "photos": { + "upload": "Subir fotos", + "setPrimary": "Establecer como principal", + "delete": "Eliminar", + "empty": "Sin fotos" + }, + "documents": { + "upload": "Subir documento", + "download": "Descargar", + "delete": "Eliminar", + "empty": "Sin documentos", + "types": { + "receipt": "Recibo", + "warranty": "Garantia", + "manual": "Manual", + "other": "Otro" + } + }, + "categories": { + "title": "Categorias", + "new": "Nueva categoria", + "edit": "Editar", + "delete": "Eliminar", + "empty": "Sin categorias", + "confirmDelete": "Realmente deseas eliminar esta categoria?", + "name": "Nombre", + "icon": "Icono", + "color": "Color", + "parent": "Categoria padre" + }, + "locations": { + "title": "Ubicaciones", + "new": "Nueva ubicacion", + "edit": "Editar", + "delete": "Eliminar", + "empty": "Sin ubicaciones", + "confirmDelete": "Realmente deseas eliminar esta ubicacion?", + "name": "Nombre", + "description": "Descripcion", + "parent": "Ubicacion padre" + }, + "import": { + "title": "Importar", + "csv": "Importar CSV", + "selectFile": "Seleccionar archivo", + "template": "Descargar plantilla", + "success": "Importacion exitosa", + "imported": "{count} objetos importados" + }, + "favorites": { + "title": "Favoritos", + "empty": "Sin favoritos" + }, + "archive": { + "title": "Archivo", + "empty": "Sin objetos archivados" + }, + "auth": { + "login": "Iniciar sesion", + "register": "Registrarse", + "logout": "Cerrar sesion", + "forgotPassword": "Olvidaste tu contrasena?", + "email": "Correo electronico", + "password": "Contrasena", + "name": "Nombre" + }, + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "create": "Crear", + "edit": "Editar", + "delete": "Eliminar", + "back": "Volver", + "loading": "Cargando...", + "error": "Se produjo un error", + "noResults": "Sin resultados", + "all": "Todos", + "none": "Ninguno" + } +} diff --git a/apps/inventory/apps/web/src/lib/i18n/locales/fr.json b/apps/inventory/apps/web/src/lib/i18n/locales/fr.json new file mode 100644 index 000000000..934331e4d --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/locales/fr.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Inventory", + "tagline": "Gerez votre inventaire" + }, + "nav": { + "items": "Objets", + "favorites": "Favoris", + "archive": "Archive", + "categories": "Categories", + "locations": "Emplacements", + "import": "Importer", + "settings": "Parametres", + "feedback": "Feedback", + "mana": "Mana" + }, + "items": { + "title": "Mes objets", + "new": "Nouvel objet", + "edit": "Modifier", + "delete": "Supprimer", + "search": "Rechercher...", + "empty": "Aucun objet trouve", + "emptyCreate": "Creez votre premier objet", + "confirmDelete": "Voulez-vous vraiment supprimer cet objet?", + "details": "Details", + "photos": "Photos", + "documents": "Documents" + }, + "item": { + "name": "Nom", + "description": "Description", + "sku": "Reference", + "category": "Categorie", + "location": "Emplacement", + "purchaseDate": "Date d'achat", + "purchasePrice": "Prix d'achat", + "currency": "Devise", + "currentValue": "Valeur actuelle", + "condition": "Etat", + "warrantyExpires": "Garantie jusqu'au", + "warrantyNotes": "Notes de garantie", + "notes": "Notes", + "quantity": "Quantite", + "favorite": "Favori", + "archive": "Archiver", + "unarchive": "Restaurer" + }, + "conditions": { + "new": "Neuf", + "like_new": "Comme neuf", + "good": "Bon", + "fair": "Acceptable", + "poor": "Mauvais" + }, + "photos": { + "upload": "Telecharger des photos", + "setPrimary": "Definir comme principale", + "delete": "Supprimer", + "empty": "Aucune photo" + }, + "documents": { + "upload": "Telecharger un document", + "download": "Telecharger", + "delete": "Supprimer", + "empty": "Aucun document", + "types": { + "receipt": "Recu", + "warranty": "Garantie", + "manual": "Manuel", + "other": "Autre" + } + }, + "categories": { + "title": "Categories", + "new": "Nouvelle categorie", + "edit": "Modifier", + "delete": "Supprimer", + "empty": "Aucune categorie", + "confirmDelete": "Voulez-vous vraiment supprimer cette categorie?", + "name": "Nom", + "icon": "Icone", + "color": "Couleur", + "parent": "Categorie parente" + }, + "locations": { + "title": "Emplacements", + "new": "Nouvel emplacement", + "edit": "Modifier", + "delete": "Supprimer", + "empty": "Aucun emplacement", + "confirmDelete": "Voulez-vous vraiment supprimer cet emplacement?", + "name": "Nom", + "description": "Description", + "parent": "Emplacement parent" + }, + "import": { + "title": "Importer", + "csv": "Import CSV", + "selectFile": "Selectionner un fichier", + "template": "Telecharger le modele", + "success": "Import reussi", + "imported": "{count} objets importes" + }, + "favorites": { + "title": "Favoris", + "empty": "Aucun favori" + }, + "archive": { + "title": "Archive", + "empty": "Aucun objet archive" + }, + "auth": { + "login": "Connexion", + "register": "S'inscrire", + "logout": "Deconnexion", + "forgotPassword": "Mot de passe oublie?", + "email": "E-mail", + "password": "Mot de passe", + "name": "Nom" + }, + "common": { + "save": "Enregistrer", + "cancel": "Annuler", + "create": "Creer", + "edit": "Modifier", + "delete": "Supprimer", + "back": "Retour", + "loading": "Chargement...", + "error": "Une erreur s'est produite", + "noResults": "Aucun resultat", + "all": "Tous", + "none": "Aucun" + } +} diff --git a/apps/inventory/apps/web/src/lib/i18n/locales/it.json b/apps/inventory/apps/web/src/lib/i18n/locales/it.json new file mode 100644 index 000000000..960f4122f --- /dev/null +++ b/apps/inventory/apps/web/src/lib/i18n/locales/it.json @@ -0,0 +1,135 @@ +{ + "app": { + "name": "Inventory", + "tagline": "Gestisci il tuo inventario" + }, + "nav": { + "items": "Oggetti", + "favorites": "Preferiti", + "archive": "Archivio", + "categories": "Categorie", + "locations": "Posizioni", + "import": "Importa", + "settings": "Impostazioni", + "feedback": "Feedback", + "mana": "Mana" + }, + "items": { + "title": "I miei oggetti", + "new": "Nuovo oggetto", + "edit": "Modifica", + "delete": "Elimina", + "search": "Cerca...", + "empty": "Nessun oggetto trovato", + "emptyCreate": "Crea il tuo primo oggetto", + "confirmDelete": "Vuoi davvero eliminare questo oggetto?", + "details": "Dettagli", + "photos": "Foto", + "documents": "Documenti" + }, + "item": { + "name": "Nome", + "description": "Descrizione", + "sku": "Riferimento", + "category": "Categoria", + "location": "Posizione", + "purchaseDate": "Data di acquisto", + "purchasePrice": "Prezzo di acquisto", + "currency": "Valuta", + "currentValue": "Valore attuale", + "condition": "Condizione", + "warrantyExpires": "Garanzia fino al", + "warrantyNotes": "Note garanzia", + "notes": "Note", + "quantity": "Quantita", + "favorite": "Preferito", + "archive": "Archivia", + "unarchive": "Ripristina" + }, + "conditions": { + "new": "Nuovo", + "like_new": "Come nuovo", + "good": "Buono", + "fair": "Accettabile", + "poor": "Scarso" + }, + "photos": { + "upload": "Carica foto", + "setPrimary": "Imposta come principale", + "delete": "Elimina", + "empty": "Nessuna foto" + }, + "documents": { + "upload": "Carica documento", + "download": "Scarica", + "delete": "Elimina", + "empty": "Nessun documento", + "types": { + "receipt": "Ricevuta", + "warranty": "Garanzia", + "manual": "Manuale", + "other": "Altro" + } + }, + "categories": { + "title": "Categorie", + "new": "Nuova categoria", + "edit": "Modifica", + "delete": "Elimina", + "empty": "Nessuna categoria", + "confirmDelete": "Vuoi davvero eliminare questa categoria?", + "name": "Nome", + "icon": "Icona", + "color": "Colore", + "parent": "Categoria padre" + }, + "locations": { + "title": "Posizioni", + "new": "Nuova posizione", + "edit": "Modifica", + "delete": "Elimina", + "empty": "Nessuna posizione", + "confirmDelete": "Vuoi davvero eliminare questa posizione?", + "name": "Nome", + "description": "Descrizione", + "parent": "Posizione padre" + }, + "import": { + "title": "Importa", + "csv": "Importa CSV", + "selectFile": "Seleziona file", + "template": "Scarica modello", + "success": "Importazione riuscita", + "imported": "{count} oggetti importati" + }, + "favorites": { + "title": "Preferiti", + "empty": "Nessun preferito" + }, + "archive": { + "title": "Archivio", + "empty": "Nessun oggetto archiviato" + }, + "auth": { + "login": "Accedi", + "register": "Registrati", + "logout": "Esci", + "forgotPassword": "Password dimenticata?", + "email": "E-mail", + "password": "Password", + "name": "Nome" + }, + "common": { + "save": "Salva", + "cancel": "Annulla", + "create": "Crea", + "edit": "Modifica", + "delete": "Elimina", + "back": "Indietro", + "loading": "Caricamento...", + "error": "Si e verificato un errore", + "noResults": "Nessun risultato", + "all": "Tutti", + "none": "Nessuno" + } +} diff --git a/apps/inventory/apps/web/src/lib/stores/auth.svelte.ts b/apps/inventory/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..d11d5642e --- /dev/null +++ b/apps/inventory/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,192 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth, type UserData } from '@manacore/shared-auth'; + +// Initialize Mana Core Auth only on the client side +const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; + +// Lazy initialization to avoid SSR issues with localStorage +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +// State +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + // Getters + get user() { + return user; + }, + get loading() { + return loading; + }, + get isLoading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + get error() { + return null; // For compatibility + }, + + /** + * Initialize auth state from stored tokens + */ + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + const authenticated = await authService.isAuthenticated(); + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + /** + * Sign in with email and password + */ + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + // Get user data from token + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + /** + * Sign up with email and password + */ + async register(email: string, password: string, _name?: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + // Note: name is not supported by Mana Core Auth signUp + const result = await authService.signUp(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + // Mana Core Auth requires separate login after signup + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + // Auto sign in after successful signup + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + /** + * Sign out + */ + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + + try { + await authService.signOut(); + user = null; + } catch (error) { + console.error('Sign out error:', error); + // Clear user even if sign out fails + user = null; + } + }, + + /** + * Send password reset email + */ + async requestPasswordReset(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.forgotPassword(email); + + if (!result.success) { + return { success: false, error: result.error || 'Password reset failed' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + /** + * Get access token for API calls + */ + async getAccessToken() { + const authService = getAuthService(); + if (!authService) { + return null; + } + return await authService.getAppToken(); + }, +}; diff --git a/apps/inventory/apps/web/src/lib/stores/categories.svelte.ts b/apps/inventory/apps/web/src/lib/stores/categories.svelte.ts new file mode 100644 index 000000000..b86d0bfa1 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/stores/categories.svelte.ts @@ -0,0 +1,111 @@ +import { categoriesApi, type CategoryWithChildren } from '$lib/api'; +import { authStore } from './auth.svelte'; +import type { Category, CreateCategoryInput, UpdateCategoryInput } from '@inventory/shared'; + +// State +let categories = $state([]); +let loading = $state(false); +let error = $state(null); + +// Flatten tree for select dropdowns +let flatCategories = $derived(flattenTree(categories)); + +function flattenTree(tree: CategoryWithChildren[], level = 0): (Category & { level: number })[] { + const result: (Category & { level: number })[] = []; + for (const node of tree) { + result.push({ ...node, level }); + if (node.children?.length) { + result.push(...flattenTree(node.children, level + 1)); + } + } + return result; +} + +async function fetchCategories() { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + categories = await categoriesApi.getAll(token || undefined); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch categories'; + } finally { + loading = false; + } +} + +async function createCategory(data: CreateCategoryInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const category = await categoriesApi.create(data, token || undefined); + await fetchCategories(); + return category; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create category'; + return null; + } finally { + loading = false; + } +} + +async function updateCategory(id: string, data: UpdateCategoryInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const category = await categoriesApi.update(id, data, token || undefined); + await fetchCategories(); + return category; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update category'; + return null; + } finally { + loading = false; + } +} + +async function deleteCategory(id: string): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + await categoriesApi.delete(id, token || undefined); + await fetchCategories(); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete category'; + return false; + } finally { + loading = false; + } +} + +function clearError() { + error = null; +} + +export const categoriesStore = { + get categories() { + return categories; + }, + get flatCategories() { + return flatCategories; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + fetchCategories, + createCategory, + updateCategory, + deleteCategory, + clearError, +}; diff --git a/apps/inventory/apps/web/src/lib/stores/index.ts b/apps/inventory/apps/web/src/lib/stores/index.ts new file mode 100644 index 000000000..9c54cd642 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/stores/index.ts @@ -0,0 +1,4 @@ +export { authStore } from './auth.svelte'; +export { itemsStore } from './items.svelte'; +export { categoriesStore } from './categories.svelte'; +export { locationsStore } from './locations.svelte'; diff --git a/apps/inventory/apps/web/src/lib/stores/items.svelte.ts b/apps/inventory/apps/web/src/lib/stores/items.svelte.ts new file mode 100644 index 000000000..0a94e3a1d --- /dev/null +++ b/apps/inventory/apps/web/src/lib/stores/items.svelte.ts @@ -0,0 +1,225 @@ +import { itemsApi } from '$lib/api'; +import { authStore } from './auth.svelte'; +import type { + Item, + ItemPhoto, + ItemDocument, + CreateItemInput, + UpdateItemInput, + ItemQueryParams, + Pagination, +} from '@inventory/shared'; + +type ItemWithDetails = Item & { photos?: ItemPhoto[]; documents?: ItemDocument[] }; + +// State +let items = $state([]); +let selectedItem = $state(null); +let pagination = $state(null); +let loading = $state(false); +let error = $state(null); + +// Filters +let filters = $state({ + page: 1, + limit: 20, + sortBy: 'createdAt', + sortOrder: 'desc', + isArchived: false, +}); + +async function fetchItems(params?: Partial) { + loading = true; + error = null; + + if (params) { + filters = { ...filters, ...params }; + } + + try { + const token = await authStore.getAccessToken(); + const result = await itemsApi.getAll(filters, token || undefined); + items = result.data; + pagination = result.pagination; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch items'; + } finally { + loading = false; + } +} + +async function fetchItem(id: string) { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + selectedItem = await itemsApi.getOne(id, token || undefined); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch item'; + selectedItem = null; + } finally { + loading = false; + } +} + +async function createItem(data: CreateItemInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const item = await itemsApi.create(data, token || undefined); + await fetchItems(); + return item; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create item'; + return null; + } finally { + loading = false; + } +} + +async function updateItem(id: string, data: UpdateItemInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const item = await itemsApi.update(id, data, token || undefined); + if (selectedItem?.id === id) { + selectedItem = { ...selectedItem, ...item }; + } + await fetchItems(); + return item; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update item'; + return null; + } finally { + loading = false; + } +} + +async function deleteItem(id: string): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + await itemsApi.delete(id, token || undefined); + if (selectedItem?.id === id) { + selectedItem = null; + } + await fetchItems(); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete item'; + return false; + } finally { + loading = false; + } +} + +async function toggleFavorite(id: string): Promise { + try { + const token = await authStore.getAccessToken(); + const item = await itemsApi.toggleFavorite(id, token || undefined); + items = items.map((i) => (i.id === id ? item : i)); + if (selectedItem?.id === id) { + selectedItem = { ...selectedItem, ...item }; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to toggle favorite'; + return false; + } +} + +async function toggleArchive(id: string): Promise { + try { + const token = await authStore.getAccessToken(); + await itemsApi.toggleArchive(id, token || undefined); + await fetchItems(); + if (selectedItem?.id === id) { + selectedItem = null; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to toggle archive'; + return false; + } +} + +async function uploadPhotos(itemId: string, files: File[]): Promise { + try { + const token = await authStore.getAccessToken(); + const photos = await itemsApi.uploadPhotos(itemId, files, token || undefined); + if (selectedItem?.id === itemId) { + selectedItem = { + ...selectedItem, + photos: [...(selectedItem.photos || []), ...photos], + }; + } + return photos; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to upload photos'; + return []; + } +} + +async function deletePhoto(itemId: string, photoId: string): Promise { + try { + const token = await authStore.getAccessToken(); + await itemsApi.deletePhoto(itemId, photoId, token || undefined); + if (selectedItem?.id === itemId && selectedItem.photos) { + selectedItem = { + ...selectedItem, + photos: selectedItem.photos.filter((p) => p.id !== photoId), + }; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete photo'; + return false; + } +} + +function clearSelection() { + selectedItem = null; +} + +function clearError() { + error = null; +} + +export const itemsStore = { + get items() { + return items; + }, + get selectedItem() { + return selectedItem; + }, + get pagination() { + return pagination; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + get filters() { + return filters; + }, + fetchItems, + fetchItem, + createItem, + updateItem, + deleteItem, + toggleFavorite, + toggleArchive, + uploadPhotos, + deletePhoto, + clearSelection, + clearError, +}; diff --git a/apps/inventory/apps/web/src/lib/stores/locations.svelte.ts b/apps/inventory/apps/web/src/lib/stores/locations.svelte.ts new file mode 100644 index 000000000..f66b15f39 --- /dev/null +++ b/apps/inventory/apps/web/src/lib/stores/locations.svelte.ts @@ -0,0 +1,111 @@ +import { locationsApi, type LocationWithChildren } from '$lib/api'; +import { authStore } from './auth.svelte'; +import type { Location, CreateLocationInput, UpdateLocationInput } from '@inventory/shared'; + +// State +let locations = $state([]); +let loading = $state(false); +let error = $state(null); + +// Flatten tree for select dropdowns +let flatLocations = $derived(flattenTree(locations)); + +function flattenTree(tree: LocationWithChildren[], level = 0): (Location & { level: number })[] { + const result: (Location & { level: number })[] = []; + for (const node of tree) { + result.push({ ...node, level }); + if (node.children?.length) { + result.push(...flattenTree(node.children, level + 1)); + } + } + return result; +} + +async function fetchLocations() { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + locations = await locationsApi.getAll(token || undefined); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to fetch locations'; + } finally { + loading = false; + } +} + +async function createLocation(data: CreateLocationInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const location = await locationsApi.create(data, token || undefined); + await fetchLocations(); + return location; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create location'; + return null; + } finally { + loading = false; + } +} + +async function updateLocation(id: string, data: UpdateLocationInput): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + const location = await locationsApi.update(id, data, token || undefined); + await fetchLocations(); + return location; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update location'; + return null; + } finally { + loading = false; + } +} + +async function deleteLocation(id: string): Promise { + loading = true; + error = null; + + try { + const token = await authStore.getAccessToken(); + await locationsApi.delete(id, token || undefined); + await fetchLocations(); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete location'; + return false; + } finally { + loading = false; + } +} + +function clearError() { + error = null; +} + +export const locationsStore = { + get locations() { + return locations; + }, + get flatLocations() { + return flatLocations; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + fetchLocations, + createLocation, + updateLocation, + deleteLocation, + clearError, +}; diff --git a/apps/inventory/apps/web/src/routes/(app)/+layout.svelte b/apps/inventory/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 000000000..1058befe0 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,210 @@ + + + diff --git a/apps/inventory/apps/web/src/routes/(app)/archive/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/archive/+page.svelte new file mode 100644 index 000000000..76caa9ee2 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/archive/+page.svelte @@ -0,0 +1,69 @@ + + + + {$_('archive.title')} - {$_('app.name')} + + +
+ + +
+ {#if itemsStore.loading} +
+
+
+ {:else if itemsStore.items.length === 0} +
+
+ + + +
+

{$_('archive.empty')}

+
+ {:else} +
+ {#each itemsStore.items as item} +
+
+

{item.name}

+ {#if item.category} +

{item.category.name}

+ {/if} +
+ +
+ {/each} +
+ {/if} +
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/categories/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/categories/+page.svelte new file mode 100644 index 000000000..9d1decaf4 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/categories/+page.svelte @@ -0,0 +1,191 @@ + + + + {$_('categories.title')} - {$_('app.name')} + + +
+ + {#snippet actions()} + + {/snippet} + + + + {#if showForm} +
+

+ {editingId ? $_('categories.edit') : $_('categories.new')} +

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ {/if} + + +
+ {#if categoriesStore.loading} +
+
+
+ {:else if categoriesStore.flatCategories.length === 0} +
+

{$_('categories.empty')}

+
+ {:else} + {#each categoriesStore.flatCategories as category} +
+
+ {#if category.color} +
+ {/if} + {category.name} +
+
+ + + +
+
+ {/each} + {/if} +
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/favorites/+page.svelte new file mode 100644 index 000000000..51dd3cb45 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/favorites/+page.svelte @@ -0,0 +1,86 @@ + + + + {$_('favorites.title')} - {$_('app.name')} + + +
+ + +
+ {#if itemsStore.loading} +
+
+
+ {:else if itemsStore.items.length === 0} +
+
+ + + +
+

{$_('favorites.empty')}

+
+ {:else} + + {/if} +
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/feedback/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/feedback/+page.svelte new file mode 100644 index 000000000..510872d8e --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/feedback/+page.svelte @@ -0,0 +1,18 @@ + + + + {$_('nav.feedback')} - {$_('app.name')} + + + diff --git a/apps/inventory/apps/web/src/routes/(app)/import/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/import/+page.svelte new file mode 100644 index 000000000..504082aa1 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/import/+page.svelte @@ -0,0 +1,120 @@ + + + + {$_('import.title')} - {$_('app.name')} + + +
+ + +
+ +
+

{$_('import.csv')}

+ + + +
+ + +
+ + + + + +
+ + {#if result} +
+

{$_('import.success')}

+

{$_('import.imported', { values: { count: result.imported } })}

+ {#if result.errors > 0} +

{result.errors} Fehler

+ {/if} +
+ {/if} + + {#if error} +
+ {error} +
+ {/if} +
+
+
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/items/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/items/+page.svelte new file mode 100644 index 000000000..c0a3ca9e1 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/items/+page.svelte @@ -0,0 +1,215 @@ + + + + {$_('items.title')} - {$_('app.name')} + + +
+ + {#snippet actions()} + + {/snippet} + + + +
+
+ +
+ + + + + + +
+ + +
+ {#if itemsStore.loading} +
+
+
+ {:else if itemsStore.items.length === 0} +
+
+ + + +
+

{$_('items.empty')}

+ +
+ {:else} + + + + {#if itemsStore.pagination && itemsStore.pagination.totalPages > 1} +
+ + + Seite {itemsStore.pagination.page} von {itemsStore.pagination.totalPages} + + +
+ {/if} + {/if} +
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/items/[id]/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/items/[id]/+page.svelte new file mode 100644 index 000000000..1d2767785 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/items/[id]/+page.svelte @@ -0,0 +1,281 @@ + + + + {item?.name || $_('common.loading')} - {$_('app.name')} + + +
+ {#if itemsStore.loading && !item} +
+
+
+ {:else if item} + + {#snippet actions()} + + + {/snippet} + + + +
+ +
+ +
+ {#if activeTab === 'details'} +
+
+
+ +

{item.description || '-'}

+
+
+ +

{item.category?.name || '-'}

+
+
+ +

{item.location?.name || '-'}

+
+
+ +

{$_(`conditions.${item.condition}`)}

+
+
+ +

{item.quantity}

+
+
+ +
+
+ +

{formatPrice(item.purchasePrice, item.currency)}

+
+
+ +

{formatDate(item.purchaseDate)}

+
+
+ +

{formatPrice(item.currentValue, item.currency)}

+
+
+ +

{formatDate(item.warrantyExpires)}

+
+
+ +

{item.notes || '-'}

+
+
+
+ +
+ + +
+ {:else if activeTab === 'photos'} +
+ + + + {#if item.photos?.length} +
+ {#each item.photos as photo} +
+ {photo.caption +
+ +
+ {#if photo.isPrimary} +
+ Primary +
+ {/if} +
+ {/each} +
+ {:else} +

{$_('photos.empty')}

+ {/if} +
+ {:else if activeTab === 'documents'} +
+ {#if item.documents?.length} +
+ {#each item.documents as doc} +
+
+ + + +
+

{doc.filename}

+

+ {$_(`documents.types.${doc.documentType}`)} +

+
+
+ +
+ {/each} +
+ {:else} +

{$_('documents.empty')}

+ {/if} +
+ {/if} +
+ {:else} +

{$_('common.error')}

+ {/if} +
diff --git a/apps/inventory/apps/web/src/routes/(app)/items/new/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/items/new/+page.svelte new file mode 100644 index 000000000..7b3153ff0 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/items/new/+page.svelte @@ -0,0 +1,175 @@ + + + + {$_('items.new')} - {$_('app.name')} + + +
+ + +
+
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + {#if itemsStore.error} +
+ {itemsStore.error} +
+ {/if} + +
+ + +
+
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/locations/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/locations/+page.svelte new file mode 100644 index 000000000..2c569e157 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/locations/+page.svelte @@ -0,0 +1,207 @@ + + + + {$_('locations.title')} - {$_('app.name')} + + +
+ + {#snippet actions()} + + {/snippet} + + + + {#if showForm} +
+

+ {editingId ? $_('locations.edit') : $_('locations.new')} +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ {/if} + + +
+ {#if locationsStore.loading} +
+
+
+ {:else if locationsStore.flatLocations.length === 0} +
+

{$_('locations.empty')}

+
+ {:else} + {#each locationsStore.flatLocations as location} +
+
+ + + + +
+ {location.name} + {#if location.description} +

{location.description}

+ {/if} +
+
+
+ + + +
+
+ {/each} + {/if} +
+
diff --git a/apps/inventory/apps/web/src/routes/(app)/mana/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/mana/+page.svelte new file mode 100644 index 000000000..b6db87dc5 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/mana/+page.svelte @@ -0,0 +1,40 @@ + + + + {$_('nav.mana')} - {$_('app.name')} + + +
+ +
+ + diff --git a/apps/inventory/apps/web/src/routes/(app)/settings/+page.svelte b/apps/inventory/apps/web/src/routes/(app)/settings/+page.svelte new file mode 100644 index 000000000..25080f2f2 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(app)/settings/+page.svelte @@ -0,0 +1,57 @@ + + + + {$_('nav.settings')} - {$_('app.name')} + + +
+ + +
+ +
+

{$_('settings.account', { default: 'Account' })}

+
+
+

{$_('settings.email', { default: 'Email' })}

+

{authStore.user?.email || '-'}

+
+ +
+
+ + +
+

+ {$_('settings.appearance', { default: 'Appearance' })} +

+

+ {$_('settings.themeInNavigation', { + default: 'Theme settings are available in the navigation.', + })} +

+
+ + +
+

{$_('settings.about', { default: 'About' })}

+
+

Inventory v1.0.0

+

Part of the ManaCore Ecosystem

+
+
+
+
diff --git a/apps/inventory/apps/web/src/routes/(auth)/+layout.svelte b/apps/inventory/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..c5fbabb3e --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,25 @@ + + +
+
+
+
+ + + +
+

{$_('app.name')}

+

{$_('app.tagline')}

+
+ {@render children()} +
+
diff --git a/apps/inventory/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/inventory/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..e4c302089 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,37 @@ + + + + {translations.titleForm} | Inventory + + + + {#snippet headerControls()} + + {/snippet} + diff --git a/apps/inventory/apps/web/src/routes/(auth)/login/+page.svelte b/apps/inventory/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..16ccf7924 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,49 @@ + + + + {translations.title} | Inventory + + + + {#snippet headerControls()} + + {/snippet} + {#snippet appSlider()} + + {/snippet} + diff --git a/apps/inventory/apps/web/src/routes/(auth)/register/+page.svelte b/apps/inventory/apps/web/src/routes/(auth)/register/+page.svelte new file mode 100644 index 000000000..e0fc57784 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,42 @@ + + + + {translations.title} | Inventory + + + + {#snippet headerControls()} + + {/snippet} + {#snippet appSlider()} + + {/snippet} + diff --git a/apps/inventory/apps/web/src/routes/+layout.svelte b/apps/inventory/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..0f933aa6a --- /dev/null +++ b/apps/inventory/apps/web/src/routes/+layout.svelte @@ -0,0 +1,17 @@ + + +{#if $isLoading} +
+
+
+{:else} + {@render children()} +{/if} diff --git a/apps/inventory/apps/web/src/routes/+page.svelte b/apps/inventory/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..992fdc2b6 --- /dev/null +++ b/apps/inventory/apps/web/src/routes/+page.svelte @@ -0,0 +1,17 @@ + + +
+
+
diff --git a/apps/inventory/apps/web/svelte.config.js b/apps/inventory/apps/web/svelte.config.js new file mode 100644 index 000000000..d28b62840 --- /dev/null +++ b/apps/inventory/apps/web/svelte.config.js @@ -0,0 +1,15 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + alias: { + $lib: './src/lib', + }, + }, +}; + +export default config; diff --git a/apps/inventory/apps/web/tsconfig.json b/apps/inventory/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/inventory/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/inventory/apps/web/vite.config.ts b/apps/inventory/apps/web/vite.config.ts new file mode 100644 index 000000000..297739adb --- /dev/null +++ b/apps/inventory/apps/web/vite.config.ts @@ -0,0 +1,43 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5188, + strictPort: true, + }, + ssr: { + noExternal: [ + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + '@manacore/shared-subscription-ui', + ], + }, + optimizeDeps: { + exclude: [ + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + '@manacore/shared-subscription-ui', + ], + }, +}); diff --git a/apps/inventory/package.json b/apps/inventory/package.json new file mode 100644 index 000000000..68d1bd4fb --- /dev/null +++ b/apps/inventory/package.json @@ -0,0 +1,22 @@ +{ + "name": "inventory", + "version": "1.0.0", + "private": true, + "description": "Inventory App - Personal Asset & Belongings Management", + "scripts": { + "dev": "turbo run dev", + "dev:backend": "pnpm --filter @inventory/backend dev", + "dev:web": "pnpm --filter @inventory/web dev", + "dev:landing": "pnpm --filter @inventory/landing dev", + "build": "turbo run build", + "lint": "turbo run lint", + "clean": "turbo run clean", + "db:push": "pnpm --filter @inventory/backend db:push", + "db:studio": "pnpm --filter @inventory/backend db:studio", + "db:seed": "pnpm --filter @inventory/backend db:seed" + }, + "devDependencies": { + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.15.0" +} diff --git a/apps/inventory/packages/shared/package.json b/apps/inventory/packages/shared/package.json new file mode 100644 index 000000000..63281ba00 --- /dev/null +++ b/apps/inventory/packages/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@inventory/shared", + "version": "1.0.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.2" + } +} diff --git a/apps/inventory/packages/shared/src/constants/index.ts b/apps/inventory/packages/shared/src/constants/index.ts new file mode 100644 index 000000000..dd842f02d --- /dev/null +++ b/apps/inventory/packages/shared/src/constants/index.ts @@ -0,0 +1,112 @@ +import type { ItemCondition, DocumentType, ContactRelationType } from '../types'; + +// Item conditions with labels +export const ITEM_CONDITIONS: { value: ItemCondition; label: string; labelDe: string }[] = [ + { value: 'new', label: 'New', labelDe: 'Neu' }, + { value: 'like_new', label: 'Like New', labelDe: 'Wie neu' }, + { value: 'good', label: 'Good', labelDe: 'Gut' }, + { value: 'fair', label: 'Fair', labelDe: 'Akzeptabel' }, + { value: 'poor', label: 'Poor', labelDe: 'Schlecht' }, +]; + +// Document types with labels +export const DOCUMENT_TYPES: { value: DocumentType; label: string; labelDe: string }[] = [ + { value: 'receipt', label: 'Receipt', labelDe: 'Kassenbon' }, + { value: 'warranty', label: 'Warranty', labelDe: 'Garantie' }, + { value: 'manual', label: 'Manual', labelDe: 'Handbuch' }, + { value: 'other', label: 'Other', labelDe: 'Sonstiges' }, +]; + +// Contact relationship types with labels +export const CONTACT_RELATION_TYPES: { + value: ContactRelationType; + label: string; + labelDe: string; +}[] = [ + { value: 'seller', label: 'Seller', labelDe: 'Verkäufer' }, + { value: 'manufacturer', label: 'Manufacturer', labelDe: 'Hersteller' }, + { value: 'service', label: 'Service', labelDe: 'Service' }, +]; + +// Common currencies +export const CURRENCIES = [ + { code: 'EUR', symbol: '€', name: 'Euro' }, + { code: 'USD', symbol: '$', name: 'US Dollar' }, + { code: 'GBP', symbol: '£', name: 'British Pound' }, + { code: 'CHF', symbol: 'CHF', name: 'Swiss Franc' }, +]; + +// Default category icons +export const CATEGORY_ICONS = [ + 'laptop', + 'smartphone', + 'tv', + 'camera', + 'headphones', + 'speaker', + 'watch', + 'game-controller', + 'car', + 'bicycle', + 'home', + 'sofa', + 'bed', + 'lamp', + 'kitchen', + 'book', + 'music', + 'art', + 'sports', + 'tools', + 'clothes', + 'jewelry', + 'bag', + 'gift', + 'box', + 'folder', +]; + +// Default category colors +export const CATEGORY_COLORS = [ + '#EF4444', // red + '#F97316', // orange + '#F59E0B', // amber + '#EAB308', // yellow + '#84CC16', // lime + '#22C55E', // green + '#10B981', // emerald + '#14B8A6', // teal + '#06B6D4', // cyan + '#0EA5E9', // sky + '#3B82F6', // blue + '#6366F1', // indigo + '#8B5CF6', // violet + '#A855F7', // purple + '#D946EF', // fuchsia + '#EC4899', // pink +]; + +// File upload limits +export const UPLOAD_LIMITS = { + maxPhotoSize: 10 * 1024 * 1024, // 10MB + maxDocumentSize: 25 * 1024 * 1024, // 25MB + maxPhotosPerItem: 10, + maxDocumentsPerItem: 20, + allowedPhotoTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/heic'], + allowedDocumentTypes: [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], +}; + +// Warranty expiry warning (days before expiry) +export const WARRANTY_WARNING_DAYS = 30; + +// Pagination defaults +export const PAGINATION = { + defaultLimit: 50, + maxLimit: 200, +}; diff --git a/apps/inventory/packages/shared/src/index.ts b/apps/inventory/packages/shared/src/index.ts new file mode 100644 index 000000000..c29c24fb5 --- /dev/null +++ b/apps/inventory/packages/shared/src/index.ts @@ -0,0 +1,5 @@ +// Types +export * from './types'; + +// Constants +export * from './constants'; diff --git a/apps/inventory/packages/shared/src/types/index.ts b/apps/inventory/packages/shared/src/types/index.ts new file mode 100644 index 000000000..a97f2dbaa --- /dev/null +++ b/apps/inventory/packages/shared/src/types/index.ts @@ -0,0 +1,254 @@ +// Item condition enum +export type ItemCondition = 'new' | 'like_new' | 'good' | 'fair' | 'poor'; + +// Document type enum +export type DocumentType = 'receipt' | 'warranty' | 'manual' | 'other'; + +// Contact relationship type +export type ContactRelationType = 'seller' | 'manufacturer' | 'service'; + +// Base item interface +export interface Item { + id: string; + userId: string; + name: string; + description?: string | null; + sku?: string | null; + categoryId?: string | null; + locationId?: string | null; + purchaseDate?: string | null; + purchasePrice?: string | null; + currency: string; + currentValue?: string | null; + condition: ItemCondition; + warrantyExpires?: string | null; + warrantyNotes?: string | null; + notes?: string | null; + quantity: number; + isFavorite: boolean; + isArchived: boolean; + createdAt: string; + updatedAt: string; + // Relationships (when loaded) + category?: Category | null; + location?: Location | null; + photos?: ItemPhoto[]; + documents?: ItemDocument[]; + contacts?: ItemContact[]; +} + +// Category interface +export interface Category { + id: string; + userId: string; + name: string; + icon?: string | null; + color?: string | null; + parentCategoryId?: string | null; + createdAt: string; + // Relationships + parent?: Category | null; + children?: Category[]; + itemCount?: number; +} + +// Category with children (for tree view) +export interface CategoryWithChildren extends Category { + children: CategoryWithChildren[]; + level?: number; +} + +// Location interface +export interface Location { + id: string; + userId: string; + name: string; + description?: string | null; + parentLocationId?: string | null; + createdAt: string; + // Relationships + parent?: Location | null; + children?: Location[]; + itemCount?: number; +} + +// Location with children (for tree view) +export interface LocationWithChildren extends Location { + children: LocationWithChildren[]; + level?: number; +} + +// Item photo interface +export interface ItemPhoto { + id: string; + itemId: string; + storageKey: string; + isPrimary: boolean; + caption?: string | null; + sortOrder: number; + createdAt: string; + // Computed + url?: string; +} + +// Item document interface +export interface ItemDocument { + id: string; + itemId: string; + storageKey: string; + documentType: DocumentType; + filename: string; + mimeType?: string | null; + fileSize?: number | null; + uploadedAt: string; + // Computed + url?: string; +} + +// Item contact link interface +export interface ItemContact { + id: string; + itemId: string; + contactId: string; + relationshipType: ContactRelationType; + createdAt: string; + // From Contacts app (when loaded) + contactName?: string; + contactEmail?: string; + contactPhone?: string; +} + +// Create/Update DTOs +export interface CreateItemInput { + name: string; + description?: string; + sku?: string; + categoryId?: string; + locationId?: string; + purchaseDate?: string; + purchasePrice?: number; + currency?: string; + currentValue?: number; + condition?: ItemCondition; + warrantyExpires?: string; + warrantyNotes?: string; + notes?: string; + quantity?: number; +} + +export interface UpdateItemInput extends Partial { + isFavorite?: boolean; + isArchived?: boolean; +} + +export interface CreateCategoryInput { + name: string; + icon?: string; + color?: string; + parentCategoryId?: string; +} + +export interface UpdateCategoryInput extends Partial {} + +export interface CreateLocationInput { + name: string; + description?: string; + parentLocationId?: string; +} + +export interface UpdateLocationInput extends Partial {} + +// Query params for items API +export interface ItemQueryParams { + search?: string; + categoryId?: string; + locationId?: string; + condition?: ItemCondition; + isFavorite?: boolean; + isArchived?: boolean; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page?: number; + limit?: number; +} + +// Pagination info +export interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +// Paginated response wrapper +export interface PaginatedResponse { + data: T[]; + pagination: Pagination; +} + +// Filter interfaces +export interface ItemFilters { + search?: string; + categoryId?: string; + locationId?: string; + condition?: ItemCondition; + isFavorite?: boolean; + isArchived?: boolean; + minPrice?: number; + maxPrice?: number; + hasWarranty?: boolean; + warrantyExpiresSoon?: boolean; + limit?: number; + offset?: number; +} + +// API Response types +export interface ItemsResponse { + items: Item[]; + total: number; +} + +export interface CategoriesResponse { + categories: Category[]; +} + +export interface LocationsResponse { + locations: Location[]; +} + +// Import/Export types +export interface ImportPreview { + totalRows: number; + validRows: number; + errors: ImportError[]; + preview: Partial[]; +} + +export interface ImportError { + row: number; + field: string; + message: string; +} + +export interface ImportResult { + imported: number; + skipped: number; + errors: ImportError[]; +} + +export interface ExportOptions { + categoryId?: string; + locationId?: string; + includeArchived?: boolean; + format?: 'csv'; +} + +// Statistics +export interface InventoryStats { + totalItems: number; + totalValue: number; + itemsByCategory: { categoryId: string; categoryName: string; count: number }[]; + itemsByLocation: { locationId: string; locationName: string; count: number }[]; + itemsByCondition: { condition: ItemCondition; count: number }[]; + warrantyExpiringSoon: number; +} diff --git a/apps/inventory/packages/shared/tsconfig.json b/apps/inventory/packages/shared/tsconfig.json new file mode 100644 index 000000000..916f65a1b --- /dev/null +++ b/apps/inventory/packages/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 037e020e3..b302e9ce5 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -63,6 +63,9 @@ const todoSvg = ``; +// Inventory icon (box/package with gradient) +const inventorySvg = ``; + /** * App icons as data URLs * Use these directly in or CSS background-image @@ -86,6 +89,7 @@ export const APP_ICONS = { clock: svgToDataUrl(clockSvg), todo: svgToDataUrl(todoSvg), mail: svgToDataUrl(mailSvg), + inventory: svgToDataUrl(inventorySvg), } as const; export type AppIconId = keyof typeof APP_ICONS; diff --git a/packages/shared-branding/src/config.ts b/packages/shared-branding/src/config.ts index 522320d38..8bdaf68d2 100644 --- a/packages/shared-branding/src/config.ts +++ b/packages/shared-branding/src/config.ts @@ -220,6 +220,19 @@ export const APP_BRANDING: Record = { logoStroke: true, logoStrokeWidth: 1.5, }, + inventory: { + id: 'inventory', + name: 'Inventory', + tagline: 'Inventory Management', + primaryColor: '#14b8a6', + secondaryColor: '#2dd4bf', + // Box/package icon + logoPath: + 'M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9', + logoViewBox: '0 0 24 24', + logoStroke: true, + logoStrokeWidth: 1.5, + }, }; /** diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index d5f3e3c69..9d4fd4b64 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -30,6 +30,7 @@ export { TodoLogo, MailLogo, MoodlitLogo, + InventoryLogo, } from './logos'; // Configuration diff --git a/packages/shared-branding/src/logos/InventoryLogo.svelte b/packages/shared-branding/src/logos/InventoryLogo.svelte new file mode 100644 index 000000000..6a0e27609 --- /dev/null +++ b/packages/shared-branding/src/logos/InventoryLogo.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/shared-branding/src/logos/index.ts b/packages/shared-branding/src/logos/index.ts index b6a1d361a..06ce0a38d 100644 --- a/packages/shared-branding/src/logos/index.ts +++ b/packages/shared-branding/src/logos/index.ts @@ -17,3 +17,4 @@ export { default as StorageLogo } from './StorageLogo.svelte'; export { default as TodoLogo } from './TodoLogo.svelte'; export { default as MailLogo } from './MailLogo.svelte'; export { default as MoodlitLogo } from './MoodlitLogo.svelte'; +export { default as InventoryLogo } from './InventoryLogo.svelte'; diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 20c5d9c5f..b781237aa 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -292,6 +292,22 @@ export const MANA_APPS: ManaApp[] = [ comingSoon: false, status: 'development', }, + { + id: 'inventory', + name: 'Inventory', + description: { + de: 'Besitz-Verwaltung', + en: 'Inventory Management', + }, + longDescription: { + de: 'Verwalte deinen Besitz mit Fotos, Kaufbelegen, Garantie-Dokumenten, Kategorien und Standorten.', + en: 'Manage your belongings with photos, receipts, warranty documents, categories, and locations.', + }, + icon: APP_ICONS.inventory, + color: '#14b8a6', + comingSoon: false, + status: 'development', + }, ]; /** @@ -376,8 +392,9 @@ export const APP_URLS: Record = { calendar: { dev: 'http://localhost:5179', prod: 'https://calendar.manacore.app' }, storage: { dev: 'http://localhost:5185', prod: 'https://storage.manacore.app' }, clock: { dev: 'http://localhost:5187', prod: 'https://clock.manacore.app' }, - todo: { dev: 'http://localhost:5188', prod: 'https://todo.manacore.app' }, + todo: { dev: 'http://localhost:5189', prod: 'https://todo.manacore.app' }, mail: { dev: 'http://localhost:5186', prod: 'https://mail.manacore.app' }, + inventory: { dev: 'http://localhost:5188', prod: 'https://inventory.manacore.app' }, }; /** diff --git a/packages/shared-branding/src/types.ts b/packages/shared-branding/src/types.ts index 8dedf3b10..5df930f6d 100644 --- a/packages/shared-branding/src/types.ts +++ b/packages/shared-branding/src/types.ts @@ -18,7 +18,8 @@ export type AppId = | 'clock' | 'todo' | 'mail' - | 'moodlit'; + | 'moodlit' + | 'inventory'; /** * App branding configuration diff --git a/packages/shared-storage/src/factory.ts b/packages/shared-storage/src/factory.ts index 9cf613367..1682ebc90 100644 --- a/packages/shared-storage/src/factory.ts +++ b/packages/shared-storage/src/factory.ts @@ -136,3 +136,13 @@ export function createStorageStorage(publicUrl?: string): StorageClient { export function createMailStorage(): StorageClient { return createStorageClient({ name: BUCKETS.MAIL }); } + +/** + * Create a storage client for the Inventory project + */ +export function createInventoryStorage(publicUrl?: string): StorageClient { + return createStorageClient({ + name: BUCKETS.INVENTORY, + publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL, + }); +} diff --git a/packages/shared-storage/src/index.ts b/packages/shared-storage/src/index.ts index 717e0b51c..f3ef3c5be 100644 --- a/packages/shared-storage/src/index.ts +++ b/packages/shared-storage/src/index.ts @@ -14,6 +14,7 @@ export { createContactsStorage, createStorageStorage, createMailStorage, + createInventoryStorage, } from './factory'; // Utilities diff --git a/packages/shared-storage/src/types.ts b/packages/shared-storage/src/types.ts index 62921a31d..5fa3e43c2 100644 --- a/packages/shared-storage/src/types.ts +++ b/packages/shared-storage/src/types.ts @@ -85,6 +85,7 @@ export const BUCKETS = { CONTACTS: 'contacts-storage', STORAGE: 'storage-storage', MAIL: 'mail-storage', + INVENTORY: 'inventory-storage', } as const; export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];