mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(inventory): add new Inventory app scaffolding
Add new Inventory management app with: - Backend NestJS setup with Drizzle schema for items, categories, locations - Web SvelteKit app with item management UI - Shared branding config (logo, icon, colors) - Storage bucket configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5fd5423f8e
commit
f1ed3e3f2e
113 changed files with 7270 additions and 2 deletions
430
apps/inventory/CLAUDE.md
Normal file
430
apps/inventory/CLAUDE.md
Normal file
|
|
@ -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
|
||||
15
apps/inventory/apps/backend/drizzle.config.ts
Normal file
15
apps/inventory/apps/backend/drizzle.config.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
8
apps/inventory/apps/backend/nest-cli.json
Normal file
8
apps/inventory/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
58
apps/inventory/apps/backend/package.json
Normal file
58
apps/inventory/apps/backend/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
32
apps/inventory/apps/backend/src/app.module.ts
Normal file
32
apps/inventory/apps/backend/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/inventory/apps/backend/src/category/category.module.ts
Normal file
10
apps/inventory/apps/backend/src/category/category.module.ts
Normal file
|
|
@ -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 {}
|
||||
159
apps/inventory/apps/backend/src/category/category.service.ts
Normal file
159
apps/inventory/apps/backend/src/category/category.service.ts
Normal file
|
|
@ -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<string, CategoryWithChildren>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
42
apps/inventory/apps/backend/src/category/dto/category.dto.ts
Normal file
42
apps/inventory/apps/backend/src/category/dto/category.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
11
apps/inventory/apps/backend/src/db/connection.ts
Normal file
11
apps/inventory/apps/backend/src/db/connection.ts
Normal file
|
|
@ -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;
|
||||
16
apps/inventory/apps/backend/src/db/database.module.ts
Normal file
16
apps/inventory/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
6
apps/inventory/apps/backend/src/db/schema/index.ts
Normal file
6
apps/inventory/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
56
apps/inventory/apps/backend/src/db/schema/items.schema.ts
Normal file
56
apps/inventory/apps/backend/src/db/schema/items.schema.ts
Normal file
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
16
apps/inventory/apps/backend/src/document/document.module.ts
Normal file
16
apps/inventory/apps/backend/src/document/document.module.ts
Normal file
|
|
@ -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 {}
|
||||
115
apps/inventory/apps/backend/src/document/document.service.ts
Normal file
115
apps/inventory/apps/backend/src/document/document.service.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
48
apps/inventory/apps/backend/src/export/export.controller.ts
Normal file
48
apps/inventory/apps/backend/src/export/export.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/inventory/apps/backend/src/export/export.module.ts
Normal file
10
apps/inventory/apps/backend/src/export/export.module.ts
Normal file
|
|
@ -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 {}
|
||||
87
apps/inventory/apps/backend/src/export/export.service.ts
Normal file
87
apps/inventory/apps/backend/src/export/export.service.ts
Normal file
|
|
@ -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<string> {
|
||||
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',
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/inventory/apps/backend/src/health/health.controller.ts
Normal file
13
apps/inventory/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/inventory/apps/backend/src/health/health.module.ts
Normal file
7
apps/inventory/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
34
apps/inventory/apps/backend/src/import/import.controller.ts
Normal file
34
apps/inventory/apps/backend/src/import/import.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
16
apps/inventory/apps/backend/src/import/import.module.ts
Normal file
16
apps/inventory/apps/backend/src/import/import.module.ts
Normal file
|
|
@ -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 {}
|
||||
149
apps/inventory/apps/backend/src/import/import.service.ts
Normal file
149
apps/inventory/apps/backend/src/import/import.service.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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';
|
||||
}
|
||||
}
|
||||
75
apps/inventory/apps/backend/src/item/dto/create-item.dto.ts
Normal file
75
apps/inventory/apps/backend/src/item/dto/create-item.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
3
apps/inventory/apps/backend/src/item/dto/index.ts
Normal file
3
apps/inventory/apps/backend/src/item/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './create-item.dto';
|
||||
export * from './update-item.dto';
|
||||
export * from './item-query.dto';
|
||||
50
apps/inventory/apps/backend/src/item/dto/item-query.dto.ts
Normal file
50
apps/inventory/apps/backend/src/item/dto/item-query.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
76
apps/inventory/apps/backend/src/item/dto/update-item.dto.ts
Normal file
76
apps/inventory/apps/backend/src/item/dto/update-item.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
60
apps/inventory/apps/backend/src/item/item.controller.ts
Normal file
60
apps/inventory/apps/backend/src/item/item.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/inventory/apps/backend/src/item/item.module.ts
Normal file
10
apps/inventory/apps/backend/src/item/item.module.ts
Normal file
|
|
@ -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 {}
|
||||
251
apps/inventory/apps/backend/src/item/item.service.ts
Normal file
251
apps/inventory/apps/backend/src/item/item.service.ts
Normal file
|
|
@ -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<number>`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<string, unknown> = { 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;
|
||||
}
|
||||
}
|
||||
30
apps/inventory/apps/backend/src/location/dto/location.dto.ts
Normal file
30
apps/inventory/apps/backend/src/location/dto/location.dto.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/inventory/apps/backend/src/location/location.module.ts
Normal file
10
apps/inventory/apps/backend/src/location/location.module.ts
Normal file
|
|
@ -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 {}
|
||||
153
apps/inventory/apps/backend/src/location/location.service.ts
Normal file
153
apps/inventory/apps/backend/src/location/location.service.ts
Normal file
|
|
@ -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<string, LocationWithChildren>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
37
apps/inventory/apps/backend/src/main.ts
Normal file
37
apps/inventory/apps/backend/src/main.ts
Normal file
|
|
@ -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<number>('PORT') || 3020;
|
||||
const corsOrigins =
|
||||
configService.get<string>('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();
|
||||
57
apps/inventory/apps/backend/src/photo/photo.controller.ts
Normal file
57
apps/inventory/apps/backend/src/photo/photo.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
16
apps/inventory/apps/backend/src/photo/photo.module.ts
Normal file
16
apps/inventory/apps/backend/src/photo/photo.module.ts
Normal file
|
|
@ -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 {}
|
||||
152
apps/inventory/apps/backend/src/photo/photo.service.ts
Normal file
152
apps/inventory/apps/backend/src/photo/photo.service.ts
Normal file
|
|
@ -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<typeof itemPhotos.$inferSelect & { url: string }> = [];
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
53
apps/inventory/apps/backend/src/storage/storage.service.ts
Normal file
53
apps/inventory/apps/backend/src/storage/storage.service.ts
Normal file
|
|
@ -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<void> {
|
||||
await this.storage.delete(key);
|
||||
}
|
||||
|
||||
async getDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
28
apps/inventory/apps/backend/tsconfig.json
Normal file
28
apps/inventory/apps/backend/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
9
apps/inventory/apps/landing/astro.config.mjs
Normal file
9
apps/inventory/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
server: {
|
||||
port: 4325,
|
||||
},
|
||||
});
|
||||
22
apps/inventory/apps/landing/package.json
Normal file
22
apps/inventory/apps/landing/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
3
apps/inventory/apps/landing/public/favicon.svg
Normal file
3
apps/inventory/apps/landing/public/favicon.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
34
apps/inventory/apps/landing/src/layouts/Layout.astro
Normal file
34
apps/inventory/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Inventory - Verwalte deinen Besitz digital. Fotos, Kaufbelege, Garantiescheine - alles an einem Ort."
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#14B8A6" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-white text-gray-900 antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
263
apps/inventory/apps/landing/src/pages/index.astro
Normal file
263
apps/inventory/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Inventory - Verwalte dein Inventar">
|
||||
<!-- Hero Section -->
|
||||
<section
|
||||
class="relative overflow-hidden bg-gradient-to-b from-primary-50 to-white py-20 sm:py-32"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-primary-100 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="w-10 h-10 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
|
||||
Behalte den <span class="text-primary-600">Überblick</span>
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-gray-600 max-w-2xl mx-auto">
|
||||
Verwalte deinen Besitz digital. Fotos, Kaufbelege, Garantiescheine - alles an einem Ort.
|
||||
Wisse immer, was du hast und wo es ist.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-4">
|
||||
<a
|
||||
href="https://app.inventory.manacore.app"
|
||||
class="rounded-full bg-primary-600 px-8 py-4 text-sm font-semibold text-white shadow-lg hover:bg-primary-500 transition-colors"
|
||||
>
|
||||
Jetzt starten
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="text-sm font-semibold leading-6 text-gray-900 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
Mehr erfahren <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 sm:py-32">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
|
||||
Alles was du brauchst
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-gray-600">Eine App für dein gesamtes Inventar</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Feature 1 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Fotos & Dokumente</h3>
|
||||
<p class="text-gray-600">
|
||||
Füge Fotos zu jedem Gegenstand hinzu. Speichere Kaufbelege, Garantiescheine und
|
||||
Handbücher digital.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Kategorien & Standorte</h3>
|
||||
<p class="text-gray-600">
|
||||
Organisiere mit hierarchischen Kategorien und Standorten. Finde alles schnell wieder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 3 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Garantie-Tracking</h3>
|
||||
<p class="text-gray-600">
|
||||
Behalte Garantie-Ablaufdaten im Blick. Nie wieder eine Garantie verpassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 4 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Wert-Übersicht</h3>
|
||||
<p class="text-gray-600">
|
||||
Behalte Kaufpreise und aktuelle Werte im Blick. Perfekt für Versicherungszwecke.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 5 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Import & Export</h3>
|
||||
<p class="text-gray-600">
|
||||
Importiere bestehende Listen per CSV. Exportiere deine Daten jederzeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 6 -->
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 p-8 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-xl bg-primary-100 flex items-center justify-center mb-6">
|
||||
<svg
|
||||
class="w-6 h-6 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">Schnelle Suche</h3>
|
||||
<p class="text-gray-600">
|
||||
Finde jeden Gegenstand in Sekunden. Filter nach Kategorie, Standort oder Zustand.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section class="bg-primary-600 py-16">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 class="text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Bereit für mehr Ordnung?
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-primary-100">
|
||||
Starte jetzt kostenlos und behalte den Überblick über deinen Besitz.
|
||||
</p>
|
||||
<a
|
||||
href="https://app.inventory.manacore.app"
|
||||
class="mt-8 inline-block rounded-full bg-white px-8 py-4 text-sm font-semibold text-primary-600 shadow-lg hover:bg-primary-50 transition-colors"
|
||||
>
|
||||
Kostenlos registrieren
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-50 py-12">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-primary-100 flex items-center justify-center">
|
||||
<svg
|
||||
class="w-4 h-4 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-900">Inventory</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ein Produkt von <a href="https://manacore.app" class="text-primary-600 hover:underline"
|
||||
>Mana Core</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</Layout>
|
||||
23
apps/inventory/apps/landing/tailwind.config.mjs
Normal file
23
apps/inventory/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -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: [],
|
||||
};
|
||||
9
apps/inventory/apps/landing/tsconfig.json
Normal file
9
apps/inventory/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/inventory/apps/landing/wrangler.toml
Normal file
3
apps/inventory/apps/landing/wrangler.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name = "inventory-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
41
apps/inventory/apps/web/package.json
Normal file
41
apps/inventory/apps/web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
37
apps/inventory/apps/web/src/app.css
Normal file
37
apps/inventory/apps/web/src/app.css
Normal file
|
|
@ -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));
|
||||
}
|
||||
13
apps/inventory/apps/web/src/app.html
Normal file
13
apps/inventory/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="de" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#14B8A6" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="h-full" data-sveltekit-preload-data="hover">
|
||||
<div class="h-full">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
51
apps/inventory/apps/web/src/lib/api/categories.ts
Normal file
51
apps/inventory/apps/web/src/lib/api/categories.ts
Normal file
|
|
@ -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<CategoryWithChildren[]> {
|
||||
return apiRequest('/api/v1/categories', {}, token);
|
||||
},
|
||||
|
||||
async getOne(id: string, token?: string): Promise<Category> {
|
||||
return apiRequest(`/api/v1/categories/${id}`, {}, token);
|
||||
},
|
||||
|
||||
async create(data: CreateCategoryInput, token?: string): Promise<Category> {
|
||||
return apiRequest(
|
||||
'/api/v1/categories',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateCategoryInput, token?: string): Promise<Category> {
|
||||
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
|
||||
);
|
||||
},
|
||||
};
|
||||
57
apps/inventory/apps/web/src/lib/api/client.ts
Normal file
57
apps/inventory/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
const API_URL = import.meta.env.PUBLIC_BACKEND_URL || 'http://localhost:3018';
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
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<T>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
token?: string
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
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}`;
|
||||
}
|
||||
4
apps/inventory/apps/web/src/lib/api/index.ts
Normal file
4
apps/inventory/apps/web/src/lib/api/index.ts
Normal file
|
|
@ -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';
|
||||
144
apps/inventory/apps/web/src/lib/api/items.ts
Normal file
144
apps/inventory/apps/web/src/lib/api/items.ts
Normal file
|
|
@ -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<PaginatedResponse<Item>> {
|
||||
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<Item & { photos: ItemPhoto[]; documents: ItemDocument[] }> {
|
||||
return apiRequest(`/api/v1/items/${id}`, {}, token);
|
||||
},
|
||||
|
||||
async create(data: CreateItemInput, token?: string): Promise<Item> {
|
||||
return apiRequest(
|
||||
'/api/v1/items',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateItemInput, token?: string): Promise<Item> {
|
||||
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<Item> {
|
||||
return apiRequest(
|
||||
`/api/v1/items/${id}/toggle-favorite`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async toggleArchive(id: string, token?: string): Promise<Item> {
|
||||
return apiRequest(
|
||||
`/api/v1/items/${id}/toggle-archive`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async uploadPhotos(itemId: string, files: File[], token?: string): Promise<ItemPhoto[]> {
|
||||
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<ItemPhoto> {
|
||||
return apiRequest(
|
||||
`/api/v1/items/${itemId}/photos/${photoId}/set-primary`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async uploadDocument(
|
||||
itemId: string,
|
||||
file: File,
|
||||
documentType: string,
|
||||
token?: string
|
||||
): Promise<ItemDocument> {
|
||||
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
|
||||
);
|
||||
},
|
||||
};
|
||||
51
apps/inventory/apps/web/src/lib/api/locations.ts
Normal file
51
apps/inventory/apps/web/src/lib/api/locations.ts
Normal file
|
|
@ -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<LocationWithChildren[]> {
|
||||
return apiRequest('/api/v1/locations', {}, token);
|
||||
},
|
||||
|
||||
async getOne(id: string, token?: string): Promise<Location> {
|
||||
return apiRequest(`/api/v1/locations/${id}`, {}, token);
|
||||
},
|
||||
|
||||
async create(data: CreateLocationInput, token?: string): Promise<Location> {
|
||||
return apiRequest(
|
||||
'/api/v1/locations',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
token
|
||||
);
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateLocationInput, token?: string): Promise<Location> {
|
||||
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
|
||||
);
|
||||
},
|
||||
};
|
||||
32
apps/inventory/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/inventory/apps/web/src/lib/components/AppSlider.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider, type AppItem } from '@manacore/shared-ui';
|
||||
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert MANA_APPS to AppItem format (German)
|
||||
const apps: AppItem[] = MANA_APPS.map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
|
||||
|
||||
const languageLabels: Record<SupportedLocale, string> = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
it: 'Italiano',
|
||||
fr: 'Francais',
|
||||
es: 'Espanol',
|
||||
};
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
function handleSelect(lang: SupportedLocale) {
|
||||
setLocale(lang);
|
||||
isOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{languageLabels[$locale as SupportedLocale] || 'Language'}
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="absolute right-0 mt-2 w-40 rounded-md border border-border bg-card shadow-lg z-50">
|
||||
{#each supportedLocales as lang}
|
||||
<button
|
||||
onclick={() => handleSelect(lang)}
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-accent transition-colors first:rounded-t-md last:rounded-b-md"
|
||||
class:bg-accent={$locale === lang}
|
||||
>
|
||||
{languageLabels[lang]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
27
apps/inventory/apps/web/src/lib/i18n/index.ts
Normal file
27
apps/inventory/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -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 };
|
||||
135
apps/inventory/apps/web/src/lib/i18n/locales/de.json
Normal file
135
apps/inventory/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
135
apps/inventory/apps/web/src/lib/i18n/locales/en.json
Normal file
135
apps/inventory/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
135
apps/inventory/apps/web/src/lib/i18n/locales/es.json
Normal file
135
apps/inventory/apps/web/src/lib/i18n/locales/es.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
135
apps/inventory/apps/web/src/lib/i18n/locales/fr.json
Normal file
135
apps/inventory/apps/web/src/lib/i18n/locales/fr.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
135
apps/inventory/apps/web/src/lib/i18n/locales/it.json
Normal file
135
apps/inventory/apps/web/src/lib/i18n/locales/it.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
192
apps/inventory/apps/web/src/lib/stores/auth.svelte.ts
Normal file
192
apps/inventory/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -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<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['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<UserData | null>(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();
|
||||
},
|
||||
};
|
||||
111
apps/inventory/apps/web/src/lib/stores/categories.svelte.ts
Normal file
111
apps/inventory/apps/web/src/lib/stores/categories.svelte.ts
Normal file
|
|
@ -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<CategoryWithChildren[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(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<Category | null> {
|
||||
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<Category | null> {
|
||||
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<boolean> {
|
||||
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,
|
||||
};
|
||||
4
apps/inventory/apps/web/src/lib/stores/index.ts
Normal file
4
apps/inventory/apps/web/src/lib/stores/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { authStore } from './auth.svelte';
|
||||
export { itemsStore } from './items.svelte';
|
||||
export { categoriesStore } from './categories.svelte';
|
||||
export { locationsStore } from './locations.svelte';
|
||||
225
apps/inventory/apps/web/src/lib/stores/items.svelte.ts
Normal file
225
apps/inventory/apps/web/src/lib/stores/items.svelte.ts
Normal file
|
|
@ -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<Item[]>([]);
|
||||
let selectedItem = $state<ItemWithDetails | null>(null);
|
||||
let pagination = $state<Pagination | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Filters
|
||||
let filters = $state<ItemQueryParams>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
isArchived: false,
|
||||
});
|
||||
|
||||
async function fetchItems(params?: Partial<ItemQueryParams>) {
|
||||
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<Item | null> {
|
||||
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<Item | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<ItemPhoto[]> {
|
||||
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<boolean> {
|
||||
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,
|
||||
};
|
||||
111
apps/inventory/apps/web/src/lib/stores/locations.svelte.ts
Normal file
111
apps/inventory/apps/web/src/lib/stores/locations.svelte.ts
Normal file
|
|
@ -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<LocationWithChildren[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(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<Location | null> {
|
||||
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<Location | null> {
|
||||
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<boolean> {
|
||||
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,
|
||||
};
|
||||
210
apps/inventory/apps/web/src/routes/(app)/+layout.svelte
Normal file
210
apps/inventory/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore, categoriesStore, locationsStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let sidebarOpen = $state(false);
|
||||
|
||||
const navItems = $derived([
|
||||
{ href: '/items', label: $_('nav.items'), icon: 'box' },
|
||||
{ href: '/favorites', label: $_('nav.favorites'), icon: 'star' },
|
||||
{ href: '/archive', label: $_('nav.archive'), icon: 'archive' },
|
||||
{ href: '/categories', label: $_('nav.categories'), icon: 'folder' },
|
||||
{ href: '/locations', label: $_('nav.locations'), icon: 'map-pin' },
|
||||
{ href: '/import', label: $_('nav.import'), icon: 'upload' },
|
||||
]);
|
||||
|
||||
const bottomNavItems = $derived([
|
||||
{ href: '/settings', label: $_('nav.settings'), icon: 'settings' },
|
||||
{ href: '/feedback', label: $_('nav.feedback'), icon: 'message-circle' },
|
||||
{ href: '/mana', label: $_('nav.mana'), icon: 'grid' },
|
||||
]);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated && !authStore.isLoading) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([categoriesStore.fetchCategories(), locationsStore.fetchLocations()]);
|
||||
});
|
||||
|
||||
function handleLogout() {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return $page.url.pathname === href || $page.url.pathname.startsWith(href + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-theme">
|
||||
<!-- Sidebar -->
|
||||
<aside class="hidden md:flex md:flex-col md:w-64 border-r border-theme">
|
||||
<!-- Logo -->
|
||||
<div class="p-4 border-b border-theme">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-theme">{$_('app.name')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors {isActive(item.href)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-theme-secondary hover:bg-theme-secondary/10 hover:text-theme'}"
|
||||
>
|
||||
<span class="w-5 h-5">
|
||||
{#if item.icon === 'box'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'star'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'archive'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'folder'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'map-pin'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'upload'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="p-4 border-t border-theme space-y-1">
|
||||
{#each bottomNavItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors {isActive(item.href)
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-theme-secondary hover:bg-theme-secondary/10 hover:text-theme'}"
|
||||
>
|
||||
<span class="w-5 h-5">
|
||||
{#if item.icon === 'settings'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'message-circle'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/></svg
|
||||
>
|
||||
{:else if item.icon === 'grid'}
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-sm font-medium">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-theme-secondary hover:bg-red-500/10 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">{$_('auth.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { itemsStore } from '$lib/stores';
|
||||
import { PageHeader, Button } from '@manacore/shared-ui';
|
||||
|
||||
onMount(() => {
|
||||
itemsStore.fetchItems({ isArchived: true });
|
||||
});
|
||||
|
||||
async function handleRestore(id: string) {
|
||||
await itemsStore.toggleArchive(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('archive.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<PageHeader title={$_('archive.title')} />
|
||||
|
||||
<div class="mt-6">
|
||||
{#if itemsStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if itemsStore.items.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-theme-secondary/10 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-theme-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-theme-secondary">{$_('archive.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each itemsStore.items as item}
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border border-theme">
|
||||
<div>
|
||||
<h3 class="font-medium text-theme">{item.name}</h3>
|
||||
{#if item.category}
|
||||
<p class="text-xs text-theme-secondary mt-1">{item.category.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => handleRestore(item.id)}>
|
||||
{$_('item.unarchive')}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
191
apps/inventory/apps/web/src/routes/(app)/categories/+page.svelte
Normal file
191
apps/inventory/apps/web/src/routes/(app)/categories/+page.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { categoriesStore } from '$lib/stores';
|
||||
import { PageHeader, Button, Input } from '@manacore/shared-ui';
|
||||
import type { Category } from '@inventory/shared';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let formData = $state({ name: '', icon: '', color: '#3B82F6', parentCategoryId: '' });
|
||||
|
||||
function startCreate(parentId?: string) {
|
||||
editingId = null;
|
||||
formData = { name: '', icon: '', color: '#3B82F6', parentCategoryId: parentId || '' };
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function startEdit(category: Category & { level?: number }) {
|
||||
editingId = category.id;
|
||||
formData = {
|
||||
name: category.name,
|
||||
icon: category.icon || '',
|
||||
color: category.color || '#3B82F6',
|
||||
parentCategoryId: category.parentCategoryId || '',
|
||||
};
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
await categoriesStore.updateCategory(editingId, formData);
|
||||
} else {
|
||||
await categoriesStore.createCategory(formData);
|
||||
}
|
||||
cancelForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm($_('categories.confirmDelete'))) {
|
||||
await categoriesStore.deleteCategory(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('categories.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<PageHeader title={$_('categories.title')}>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => startCreate()}>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('categories.new')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Form -->
|
||||
{#if showForm}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="mt-6 p-4 rounded-xl border border-theme bg-surface space-y-4"
|
||||
>
|
||||
<h3 class="font-medium text-theme">
|
||||
{editingId ? $_('categories.edit') : $_('categories.new')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('categories.name')} *</label>
|
||||
<Input bind:value={formData.name} required />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('categories.icon')}</label>
|
||||
<Input bind:value={formData.icon} placeholder="laptop, home, car..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('categories.color')}</label>
|
||||
<input
|
||||
type="color"
|
||||
bind:value={formData.color}
|
||||
class="w-full h-10 rounded-lg border border-theme cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('categories.parent')}</label>
|
||||
<select
|
||||
bind:value={formData.parentCategoryId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('common.none')}</option>
|
||||
{#each categoriesStore.flatCategories.filter((c) => c.id !== editingId) as cat}
|
||||
<option value={cat.id}>{' '.repeat(cat.level)}{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" onclick={cancelForm}>{$_('common.cancel')}</Button>
|
||||
<Button type="submit" disabled={!formData.name}>{$_('common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="mt-6 space-y-2">
|
||||
{#if categoriesStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if categoriesStore.flatCategories.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-theme-secondary">{$_('categories.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each categoriesStore.flatCategories as category}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-theme hover:border-primary transition-colors"
|
||||
style="margin-left: {category.level * 1.5}rem"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if category.color}
|
||||
<div class="w-4 h-4 rounded-full" style="background-color: {category.color}"></div>
|
||||
{/if}
|
||||
<span class="font-medium text-theme">{category.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => startCreate(category.id)}
|
||||
class="p-1 text-theme-secondary hover:text-primary transition-colors"
|
||||
title="Unterkategorie hinzufügen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEdit(category)}
|
||||
class="p-1 text-theme-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(category.id)}
|
||||
class="p-1 text-theme-secondary hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { itemsStore } from '$lib/stores';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
|
||||
onMount(() => {
|
||||
itemsStore.fetchItems({ isFavorite: true, isArchived: false });
|
||||
});
|
||||
|
||||
function formatPrice(price: string | null | undefined, currency: string): string {
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(Number(price));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('favorites.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<PageHeader title={$_('favorites.title')} />
|
||||
|
||||
<div class="mt-6">
|
||||
{#if itemsStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if itemsStore.items.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-theme-secondary/10 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-theme-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-theme-secondary">{$_('favorites.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each itemsStore.items as item}
|
||||
<a
|
||||
href="/items/{item.id}"
|
||||
class="group block rounded-xl border border-theme bg-surface p-4 transition-all hover:border-primary hover:shadow-lg"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-theme truncate">{item.name}</h3>
|
||||
{#if item.category}
|
||||
<p class="text-xs text-theme-secondary mt-1">{item.category.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<svg
|
||||
class="w-5 h-5 text-yellow-500 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<span class="text-theme font-medium">
|
||||
{formatPrice(item.purchasePrice, item.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores';
|
||||
|
||||
const feedbackService = createFeedbackService({
|
||||
appId: 'inventory',
|
||||
apiUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||
getAuthToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.feedback')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<FeedbackPage {feedbackService} appName={$_('app.name')} currentUserId={authStore.user?.id} />
|
||||
120
apps/inventory/apps/web/src/routes/(app)/import/+page.svelte
Normal file
120
apps/inventory/apps/web/src/routes/(app)/import/+page.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader, Button } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores';
|
||||
import { getDownloadUrl } from '$lib/api';
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let importing = $state(false);
|
||||
let result = $state<{ imported: number; errors: number } | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function handleImport(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
|
||||
importing = true;
|
||||
error = null;
|
||||
result = null;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', input.files[0]);
|
||||
|
||||
try {
|
||||
const token = await authStore.getAccessToken();
|
||||
const response = await fetch(getDownloadUrl('/api/v1/import/csv'), {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Import failed');
|
||||
}
|
||||
|
||||
result = await response.json();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Import failed';
|
||||
} finally {
|
||||
importing = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
window.open(getDownloadUrl('/api/v1/import/template'), '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('import.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<PageHeader title={$_('import.title')} />
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- CSV Import -->
|
||||
<div class="p-6 rounded-xl border border-theme bg-surface">
|
||||
<h3 class="font-medium text-theme mb-4">{$_('import.csv')}</h3>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
class="hidden"
|
||||
onchange={handleImport}
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Button onclick={downloadTemplate} variant="outline">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.template')}
|
||||
</Button>
|
||||
|
||||
<div class="border-2 border-dashed border-theme rounded-xl p-8 text-center">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto text-theme-secondary mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Button onclick={() => fileInput.click()} disabled={importing}>
|
||||
{importing ? $_('common.loading') : $_('import.selectFile')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if result}
|
||||
<div class="p-4 rounded-lg bg-green-500/10 text-green-600">
|
||||
<p class="font-medium">{$_('import.success')}</p>
|
||||
<p class="text-sm">{$_('import.imported', { values: { count: result.imported } })}</p>
|
||||
{#if result.errors > 0}
|
||||
<p class="text-sm text-yellow-600">{result.errors} Fehler</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-4 rounded-lg bg-red-500/10 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
215
apps/inventory/apps/web/src/routes/(app)/items/+page.svelte
Normal file
215
apps/inventory/apps/web/src/routes/(app)/items/+page.svelte
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { itemsStore, categoriesStore, locationsStore } from '$lib/stores';
|
||||
import { PageHeader, Button, Input } from '@manacore/shared-ui';
|
||||
import type { ItemCondition } from '@inventory/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
onMount(() => {
|
||||
itemsStore.fetchItems();
|
||||
});
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchQuery = value;
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
itemsStore.fetchItems({ search: value, page: 1 });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleFilterChange(key: string, value: string | undefined) {
|
||||
itemsStore.fetchItems({ [key]: value || undefined, page: 1 });
|
||||
}
|
||||
|
||||
function getConditionLabel(condition: ItemCondition): string {
|
||||
return $_(`conditions.${condition}`);
|
||||
}
|
||||
|
||||
function formatPrice(price: string | null | undefined, currency: string): string {
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(Number(price));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('items.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
<PageHeader title={$_('items.title')}>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => goto('/items/new')}>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('items.new')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-6 flex flex-wrap gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$_('items.search')}
|
||||
value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
class="rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
onchange={(e) => handleFilterChange('categoryId', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{$_('common.all')} {$_('nav.categories')}</option>
|
||||
{#each categoriesStore.flatCategories as category}
|
||||
<option value={category.id}>{' '.repeat(category.level)}{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
onchange={(e) => handleFilterChange('locationId', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{$_('common.all')} {$_('nav.locations')}</option>
|
||||
{#each locationsStore.flatLocations as location}
|
||||
<option value={location.id}>{' '.repeat(location.level)}{location.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
onchange={(e) => handleFilterChange('condition', (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">{$_('common.all')} {$_('item.condition')}</option>
|
||||
<option value="new">{$_('conditions.new')}</option>
|
||||
<option value="like_new">{$_('conditions.like_new')}</option>
|
||||
<option value="good">{$_('conditions.good')}</option>
|
||||
<option value="fair">{$_('conditions.fair')}</option>
|
||||
<option value="poor">{$_('conditions.poor')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Items List -->
|
||||
<div class="mt-6">
|
||||
{#if itemsStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if itemsStore.items.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-full bg-theme-secondary/10 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-theme-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-theme-secondary">{$_('items.empty')}</p>
|
||||
<Button class="mt-4" onclick={() => goto('/items/new')}>
|
||||
{$_('items.emptyCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each itemsStore.items as item}
|
||||
<a
|
||||
href="/items/{item.id}"
|
||||
class="group block rounded-xl border border-theme bg-surface p-4 transition-all hover:border-primary hover:shadow-lg"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-theme truncate">{item.name}</h3>
|
||||
{#if item.category}
|
||||
<p class="text-xs text-theme-secondary mt-1">{item.category.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.isFavorite}
|
||||
<svg
|
||||
class="w-5 h-5 text-yellow-500 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between text-sm">
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs bg-theme-secondary/10 text-theme-secondary"
|
||||
>
|
||||
{getConditionLabel(item.condition)}
|
||||
</span>
|
||||
<span class="text-theme font-medium">
|
||||
{formatPrice(item.purchasePrice, item.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if item.location}
|
||||
<div class="mt-3 flex items-center gap-1 text-xs text-theme-secondary">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{item.location.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if itemsStore.pagination && itemsStore.pagination.totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={itemsStore.pagination.page <= 1}
|
||||
onclick={() => itemsStore.fetchItems({ page: itemsStore.pagination!.page - 1 })}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<span class="text-sm text-theme-secondary">
|
||||
Seite {itemsStore.pagination.page} von {itemsStore.pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={itemsStore.pagination.page >= itemsStore.pagination.totalPages}
|
||||
onclick={() => itemsStore.fetchItems({ page: itemsStore.pagination!.page + 1 })}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
281
apps/inventory/apps/web/src/routes/(app)/items/[id]/+page.svelte
Normal file
281
apps/inventory/apps/web/src/routes/(app)/items/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { itemsStore } from '$lib/stores';
|
||||
import { PageHeader, Button } from '@manacore/shared-ui';
|
||||
|
||||
let activeTab = $state<'details' | 'photos' | 'documents'>('details');
|
||||
let fileInput: HTMLInputElement | undefined;
|
||||
|
||||
const item = $derived(itemsStore.selectedItem);
|
||||
|
||||
onMount(() => {
|
||||
const id = $page.params.id;
|
||||
if (id) {
|
||||
itemsStore.fetchItem(id);
|
||||
}
|
||||
});
|
||||
|
||||
function formatPrice(price: string | null | undefined, currency: string): string {
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(Number(price));
|
||||
}
|
||||
|
||||
function formatDate(date: string | null | undefined): string {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
if (item) {
|
||||
await itemsStore.toggleFavorite(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleArchive() {
|
||||
if (item) {
|
||||
await itemsStore.toggleArchive(item.id);
|
||||
goto('/items');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (item && confirm($_('items.confirmDelete'))) {
|
||||
await itemsStore.deleteItem(item.id);
|
||||
goto('/items');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePhotoUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length && item) {
|
||||
await itemsStore.uploadPhotos(item.id, Array.from(input.files));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePhoto(photoId: string) {
|
||||
if (item) {
|
||||
await itemsStore.deletePhoto(item.id, photoId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{item?.name || $_('common.loading')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6">
|
||||
{#if itemsStore.loading && !item}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if item}
|
||||
<PageHeader title={item.name} backHref="/items">
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" onclick={handleToggleFavorite}>
|
||||
<svg
|
||||
class="w-4 h-4 {item.isFavorite ? 'text-yellow-500 fill-current' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button onclick={() => goto(`/items/${item.id}/edit`)}>
|
||||
{$_('items.edit')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="mt-6 border-b border-theme">
|
||||
<nav class="flex gap-4">
|
||||
{#each ['details', 'photos', 'documents'] as tab}
|
||||
<button
|
||||
onclick={() => (activeTab = tab as typeof activeTab)}
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === tab
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-theme-secondary hover:text-theme'}"
|
||||
>
|
||||
{$_(`items.${tab}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
{#if activeTab === 'details'}
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.description')}</label>
|
||||
<p class="text-theme">{item.description || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.category')}</label>
|
||||
<p class="text-theme">{item.category?.name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.location')}</label>
|
||||
<p class="text-theme">{item.location?.name || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.condition')}</label>
|
||||
<p class="text-theme">{$_(`conditions.${item.condition}`)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.quantity')}</label>
|
||||
<p class="text-theme">{item.quantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.purchasePrice')}</label>
|
||||
<p class="text-theme">{formatPrice(item.purchasePrice, item.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.purchaseDate')}</label>
|
||||
<p class="text-theme">{formatDate(item.purchaseDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.currentValue')}</label>
|
||||
<p class="text-theme">{formatPrice(item.currentValue, item.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.warrantyExpires')}</label>
|
||||
<p class="text-theme">{formatDate(item.warrantyExpires)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-theme-secondary">{$_('item.notes')}</label>
|
||||
<p class="text-theme whitespace-pre-wrap">{item.notes || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-theme flex gap-3">
|
||||
<Button variant="outline" onclick={handleToggleArchive}>
|
||||
{item.isArchived ? $_('item.unarchive') : $_('item.archive')}
|
||||
</Button>
|
||||
<Button variant="outline" class="text-red-500 hover:bg-red-500/10" onclick={handleDelete}>
|
||||
{$_('items.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if activeTab === 'photos'}
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handlePhotoUpload}
|
||||
/>
|
||||
<Button onclick={() => fileInput?.click()}>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{$_('photos.upload')}
|
||||
</Button>
|
||||
|
||||
{#if item.photos?.length}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{#each item.photos as photo}
|
||||
<div
|
||||
class="relative group aspect-square rounded-lg overflow-hidden bg-theme-secondary/10"
|
||||
>
|
||||
<img
|
||||
src={photo.storageKey}
|
||||
alt={photo.caption || item.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleDeletePhoto(photo.id)}
|
||||
class="p-2 rounded-full bg-red-500 text-white hover:bg-red-600"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if photo.isPrimary}
|
||||
<div
|
||||
class="absolute top-2 left-2 px-2 py-1 rounded text-xs bg-primary text-white"
|
||||
>
|
||||
Primary
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-theme-secondary text-center py-8">{$_('photos.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'documents'}
|
||||
<div class="space-y-4">
|
||||
{#if item.documents?.length}
|
||||
<div class="space-y-2">
|
||||
{#each item.documents as doc}
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-theme">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="w-8 h-8 text-theme-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-theme">{doc.filename}</p>
|
||||
<p class="text-xs text-theme-secondary">
|
||||
{$_(`documents.types.${doc.documentType}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
{$_('documents.download')}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-theme-secondary text-center py-8">{$_('documents.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-theme-secondary py-12">{$_('common.error')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
175
apps/inventory/apps/web/src/routes/(app)/items/new/+page.svelte
Normal file
175
apps/inventory/apps/web/src/routes/(app)/items/new/+page.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { itemsStore, categoriesStore, locationsStore } from '$lib/stores';
|
||||
import { PageHeader, Button, Input } from '@manacore/shared-ui';
|
||||
import type { CreateItemInput } from '@inventory/shared';
|
||||
|
||||
let formData = $state<CreateItemInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
condition: 'good',
|
||||
currency: 'EUR',
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const item = await itemsStore.createItem(formData);
|
||||
if (item) {
|
||||
goto(`/items/${item.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('items.new')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<PageHeader title={$_('items.new')} backHref="/items" />
|
||||
|
||||
<form onsubmit={handleSubmit} class="mt-6 space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.name')} *</label>
|
||||
<Input bind:value={formData.name} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.description')}</label>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows={3}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.category')}</label>
|
||||
<select
|
||||
bind:value={formData.categoryId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('common.none')}</option>
|
||||
{#each categoriesStore.flatCategories as category}
|
||||
<option value={category.id}>{' '.repeat(category.level)}{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.location')}</label>
|
||||
<select
|
||||
bind:value={formData.locationId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('common.none')}</option>
|
||||
{#each locationsStore.flatLocations as location}
|
||||
<option value={location.id}>{' '.repeat(location.level)}{location.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.purchasePrice')}</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
bind:value={formData.purchasePrice}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.currency')}</label>
|
||||
<select
|
||||
bind:value={formData.currency}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CHF">CHF</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.purchaseDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={formData.purchaseDate}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.condition')}</label>
|
||||
<select
|
||||
bind:value={formData.condition}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="new">{$_('conditions.new')}</option>
|
||||
<option value="like_new">{$_('conditions.like_new')}</option>
|
||||
<option value="good">{$_('conditions.good')}</option>
|
||||
<option value="fair">{$_('conditions.fair')}</option>
|
||||
<option value="poor">{$_('conditions.poor')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1"
|
||||
>{$_('item.warrantyExpires')}</label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={formData.warrantyExpires}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.quantity')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.quantity}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('item.notes')}</label>
|
||||
<textarea
|
||||
bind:value={formData.notes}
|
||||
rows={3}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if itemsStore.error}
|
||||
<div class="p-3 rounded-lg bg-red-500/10 text-red-500 text-sm">
|
||||
{itemsStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" onclick={() => goto('/items')}>
|
||||
{$_('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={itemsStore.loading || !formData.name}>
|
||||
{itemsStore.loading ? $_('common.loading') : $_('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
207
apps/inventory/apps/web/src/routes/(app)/locations/+page.svelte
Normal file
207
apps/inventory/apps/web/src/routes/(app)/locations/+page.svelte
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { locationsStore } from '$lib/stores';
|
||||
import { PageHeader, Button, Input } from '@manacore/shared-ui';
|
||||
import type { Location } from '@inventory/shared';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let formData = $state({ name: '', description: '', parentLocationId: '' });
|
||||
|
||||
function startCreate(parentId?: string) {
|
||||
editingId = null;
|
||||
formData = { name: '', description: '', parentLocationId: parentId || '' };
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function startEdit(location: Location & { level?: number }) {
|
||||
editingId = location.id;
|
||||
formData = {
|
||||
name: location.name,
|
||||
description: location.description || '',
|
||||
parentLocationId: location.parentLocationId || '',
|
||||
};
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (editingId) {
|
||||
await locationsStore.updateLocation(editingId, formData);
|
||||
} else {
|
||||
await locationsStore.createLocation(formData);
|
||||
}
|
||||
cancelForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm($_('locations.confirmDelete'))) {
|
||||
await locationsStore.deleteLocation(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('locations.title')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<PageHeader title={$_('locations.title')}>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => startCreate()}>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('locations.new')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<!-- Form -->
|
||||
{#if showForm}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
class="mt-6 p-4 rounded-xl border border-theme bg-surface space-y-4"
|
||||
>
|
||||
<h3 class="font-medium text-theme">
|
||||
{editingId ? $_('locations.edit') : $_('locations.new')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('locations.name')} *</label>
|
||||
<Input bind:value={formData.name} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1"
|
||||
>{$_('locations.description')}</label
|
||||
>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows={2}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-theme mb-1">{$_('locations.parent')}</label>
|
||||
<select
|
||||
bind:value={formData.parentLocationId}
|
||||
class="w-full rounded-lg border border-theme bg-theme px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{$_('common.none')}</option>
|
||||
{#each locationsStore.flatLocations.filter((l) => l.id !== editingId) as loc}
|
||||
<option value={loc.id}>{' '.repeat(loc.level)}{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" onclick={cancelForm}>{$_('common.cancel')}</Button>
|
||||
<Button type="submit" disabled={!formData.name}>{$_('common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Locations List -->
|
||||
<div class="mt-6 space-y-2">
|
||||
{#if locationsStore.loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if locationsStore.flatLocations.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-theme-secondary">{$_('locations.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each locationsStore.flatLocations as location}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-theme hover:border-primary transition-colors"
|
||||
style="margin-left: {location.level * 1.5}rem"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-theme-secondary"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-medium text-theme">{location.name}</span>
|
||||
{#if location.description}
|
||||
<p class="text-xs text-theme-secondary">{location.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => startCreate(location.id)}
|
||||
class="p-1 text-theme-secondary hover:text-primary transition-colors"
|
||||
title="Unterstandort hinzufügen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEdit(location)}
|
||||
class="p-1 text-theme-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(location.id)}
|
||||
class="p-1 text-theme-secondary hover:text-red-500 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
40
apps/inventory/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
40
apps/inventory/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
|
||||
|
||||
function handleSubscribe(planId: string) {
|
||||
console.log('Subscribe to plan:', planId);
|
||||
alert(`Abo "${planId}" ausgewählt. Bezahlsystem wird noch integriert.`);
|
||||
}
|
||||
|
||||
function handleBuyPackage(packageId: string) {
|
||||
console.log('Buy package:', packageId);
|
||||
alert(`Paket "${packageId}" ausgewählt. Bezahlsystem wird noch integriert.`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.mana')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mana-page">
|
||||
<SubscriptionPage
|
||||
appName="Inventory"
|
||||
onSubscribe={handleSubscribe}
|
||||
onBuyPackage={handleBuyPackage}
|
||||
currentPlanId="free"
|
||||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mana-page {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.settings')} - {$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<PageHeader title={$_('nav.settings')} />
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Account Section -->
|
||||
<div class="p-4 rounded-xl border border-theme bg-surface">
|
||||
<h3 class="font-medium text-theme mb-4">{$_('settings.account', { default: 'Account' })}</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm text-muted">{$_('settings.email', { default: 'Email' })}</p>
|
||||
<p class="text-theme">{authStore.user?.email || '-'}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-red-500 hover:text-red-600 text-sm font-medium"
|
||||
onclick={() => {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}}
|
||||
>
|
||||
{$_('settings.logout', { default: 'Sign Out' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="p-4 rounded-xl border border-theme bg-surface">
|
||||
<h3 class="font-medium text-theme mb-4">
|
||||
{$_('settings.appearance', { default: 'Appearance' })}
|
||||
</h3>
|
||||
<p class="text-sm text-muted">
|
||||
{$_('settings.themeInNavigation', {
|
||||
default: 'Theme settings are available in the navigation.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="p-4 rounded-xl border border-theme bg-surface">
|
||||
<h3 class="font-medium text-theme mb-4">{$_('settings.about', { default: 'About' })}</h3>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-muted">Inventory v1.0.0</p>
|
||||
<p class="text-sm text-muted">Part of the ManaCore Ecosystem</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
apps/inventory/apps/web/src/routes/(auth)/+layout.svelte
Normal file
25
apps/inventory/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-theme p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 mb-4">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-theme">{$_('app.name')}</h1>
|
||||
<p class="text-sm text-theme-secondary mt-1">{$_('app.tagline')}</p>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { InventoryLogo } from '@manacore/shared-branding';
|
||||
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
|
||||
|
||||
async function handleRequestReset(email: string) {
|
||||
return authStore.requestPasswordReset(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.titleForm} | Inventory</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Inventory"
|
||||
logo={InventoryLogo}
|
||||
primaryColor="#14b8a6"
|
||||
onForgotPassword={handleRequestReset}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f0fdfa"
|
||||
darkBackground="#134e4a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
49
apps/inventory/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
49
apps/inventory/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { InventoryLogo } from '@manacore/shared-branding';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Inventory</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Inventory"
|
||||
logo={InventoryLogo}
|
||||
primaryColor="#14b8a6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f0fdfa"
|
||||
darkBackground="#134e4a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { InventoryLogo } from '@manacore/shared-branding';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string, name?: string) {
|
||||
return authStore.register(email, password, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Inventory</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Inventory"
|
||||
logo={InventoryLogo}
|
||||
primaryColor="#14b8a6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f0fdfa"
|
||||
darkBackground="#134e4a"
|
||||
{translations}
|
||||
>
|
||||
{#snippet headerControls()}
|
||||
<LanguageSelector />
|
||||
{/snippet}
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
17
apps/inventory/apps/web/src/routes/+layout.svelte
Normal file
17
apps/inventory/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import '$lib/i18n';
|
||||
import { isLoading } from 'svelte-i18n';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if $isLoading}
|
||||
<div class="flex h-screen items-center justify-center">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
17
apps/inventory/apps/web/src/routes/+page.svelte
Normal file
17
apps/inventory/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/items');
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen items-center justify-center">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
15
apps/inventory/apps/web/svelte.config.js
Normal file
15
apps/inventory/apps/web/svelte.config.js
Normal file
|
|
@ -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;
|
||||
14
apps/inventory/apps/web/tsconfig.json
Normal file
14
apps/inventory/apps/web/tsconfig.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
43
apps/inventory/apps/web/vite.config.ts
Normal file
43
apps/inventory/apps/web/vite.config.ts
Normal file
|
|
@ -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',
|
||||
],
|
||||
},
|
||||
});
|
||||
22
apps/inventory/package.json
Normal file
22
apps/inventory/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
16
apps/inventory/packages/shared/package.json
Normal file
16
apps/inventory/packages/shared/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
112
apps/inventory/packages/shared/src/constants/index.ts
Normal file
112
apps/inventory/packages/shared/src/constants/index.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue