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:
Till-JS 2025-12-05 04:19:36 +01:00
parent 5fd5423f8e
commit f1ed3e3f2e
113 changed files with 7270 additions and 2 deletions

430
apps/inventory/CLAUDE.md Normal file
View 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

View 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,
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View 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"
}
}

View 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 {}

View file

@ -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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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;

View 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 {}

View file

@ -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;

View 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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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;

View file

@ -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);
}
}

View 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 {}

View 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 };
}
}

View 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);
}
}

View 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 {}

View 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',
],
});
}
}

View 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(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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);
}
}

View 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 {}

View 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';
}
}

View 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;
}

View file

@ -0,0 +1,3 @@
export * from './create-item.dto';
export * from './update-item.dto';
export * from './item-query.dto';

View 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;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View file

@ -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);
}
}

View 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 {}

View 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;
}
}

View 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();

View 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);
}
}

View 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 {}

View 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 };
}
}

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View 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}`;
}
}

View 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"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
server: {
port: 4325,
},
});

View 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"
}
}

View 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

View 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>

View 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>

View 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: [],
};

View file

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,3 @@
name = "inventory-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View 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"
}

View 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));
}

View 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>

View 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
);
},
};

View 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}`;
}

View 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';

View 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
);
},
};

View 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
);
},
};

View 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}
/>

View file

@ -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>

View 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 };

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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();
},
};

View 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,
};

View 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';

View 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,
};

View 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,
};

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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} />

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View 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}

View 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>

View 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;

View 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"
}
}

View 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',
],
},
});

View 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"
}

View 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"
}
}

View 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