Initial commit: Enterprise Asset Management System - Imageupload, Add and Edit Assets, ShadcnUI

This commit is contained in:
Jason Fraser 2025-07-14 06:35:45 -04:00
commit 2271d6ab00
58 changed files with 25136 additions and 0 deletions

300
.gitignore vendored Normal file
View File

@ -0,0 +1,300 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
*.lcov
# Production builds
dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Docker
.docker
# Database
*.db
*.sqlite
# Directus
directus/uploads/*
directus/extensions/*
# OS generated files
Thumbs.db
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Build tools
.grunt
.sass-cache
# Temporary folders
tmp/
temp/
# Backups
backups/
*.sql
# Docker volumes and data
docker-data/
postgres-data/
directus-data/
# PM2 logs
.pm2/
# IDE and Claude files
.claude/
.cursor/
.vscode/settings.json
.vscode/launch.json
# Mac specific
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows specific
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# JetBrains IDEs
.idea/
*.iml
*.iws
*.ipr
out/
# Eclipse
.project
.metadata
.settings/
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.classpath
.factorypath
# SublimeText
*.sublime-project
*.sublime-workspace
# Application specific
upload/
uploads/
public/uploads/
assets/uploads/
# API keys and sensitive data
.env.production
.env.staging
secrets.json
config/secrets.json
# Build artifacts
*.tgz
*.tar.gz
*.zip
# Test coverage
coverage/
.nyc_output/
lcov.info
# Webpack
.webpack/
# Parcel
.parcel-cache/
.cache/
# Next.js
.next/
out/
# Nuxt.js
.nuxt/
dist/
# Vue.js
.vuepress/dist/
# Storybook
storybook-static/
# Temporary files
.tmp/
.temp/
# Firebase
.firebase/
firebase-debug.log
# Serverless
.serverless/
# AWS
.aws/
# Google Cloud
.gcloud/
# Terraform
*.tfstate
*.tfstate.backup
.terraform/
# Ansible
*.retry
# Vagrant
.vagrant/
# Local development
local/
dev/
test-data/
# Logs
logs/
*.log.*
# Runtime
runtime/
# SSL certificates
*.pem
*.key
*.crt
*.csr
*.p12
*.pfx
# API documentation
api-docs/
documentation/build/
# Monitoring
newrelic_agent.log
# Profiling
*.prof
# Node.js memory dumps
*.heapsnapshot
# Python (if any Python scripts)
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
env.bak/
venv.bak/
# Ruby (if any Ruby scripts)
*.gem
*.rbc
/.config
/coverage/
/InstalledFiles
/pkg/
/spec/reports/
/test/tmp/
/test/version_tmp/
/tmp/
# Java (if any Java components)
*.class
*.jar
*.war
*.ear
*.nar
hs_err_pid*
# Go (if any Go components)
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Dependency directories for Go
vendor/

357
APP_DATA_FLOW.md Normal file
View File

@ -0,0 +1,357 @@
# App Data Flow Documentation
This document explains the complete data flow architecture of the Enterprise Asset Management application, using the Assets view as an example.
## 📊 Data Flow Architecture Overview
```
Assets View → Assets Store → Asset Repository → Base Repository → Directus API → Database
↓ ↓ ↓ ↓ ↓ ↓
User UI State Mgmt Business Logic HTTP Client REST API PostgreSQL
```
## 🔄 Step-by-Step Data Journey
### **Step 1: User Navigates to Assets View**
**File:** `/frontend/src/views/Assets.vue`
```javascript
// When component mounts
onMounted(async () => {
uiStore.setPageTitle('Assets');
await loadData(); // This starts the data flow
});
```
### **Step 2: View Calls the Assets Store**
**File:** `/frontend/src/views/Assets.vue` → Lines 427-429
```javascript
const loadData = async () => {
// Authentication happens first
await authService.ensureAuthenticated();
// Then data loading begins
await assetsStore.fetchAssets(); // 🎯 This starts the chain
await assetsStore.fetchCategories();
await assetsStore.fetchLocations();
};
```
### **Step 3: Assets Store Coordinates the Request**
**File:** `/frontend/src/stores/assets.js` → Lines 85-105
```javascript
async fetchAssets(params = {}) {
this.isLoading = true; // 📊 Update UI state
this.error = null;
try {
const queryParams = {
page: this.pagination.page,
limit: this.pagination.limit,
...params,
};
// 🔗 Delegate to repository layer
const response = await assetRepository.getAll(queryParams);
// 📦 Store data in state
this.assets = response.data || [];
this.pagination.total = response.meta?.total_count || 0;
} catch (error) {
this.error = error.message;
throw error;
} finally {
this.isLoading = false; // 📊 Update UI state
}
}
```
**🎯 Store Responsibilities:**
- **State Management** - Holds assets data in reactive state
- **Loading States** - Manages `isLoading`, `error` flags
- **Pagination** - Tracks page, limit, total count
- **Cache Invalidation** - Calls cache service when data changes
- **Error Handling** - Catches and stores error messages
### **Step 4: Asset Repository Adds Business Logic**
**File:** `/frontend/src/repositories/AssetRepository.js` → Lines 9-30
```javascript
async getAll(params = {}) {
try {
const searchParams = {
...params,
// 🔗 Define what related data to fetch
fields: [
'*', // All asset fields
'category_id.id', // Category relationship
'category_id.name',
'category_id.color',
'location_id.id', // Location relationship
'location_id.name',
'location_id.building',
'location_id.floor',
'vendor_id.id', // Vendor relationship
'vendor_id.name',
],
};
// 🔗 Delegate to base repository
return await super.getAll(searchParams);
} catch (error) {
throw this.handleError(error);
}
}
```
**🎯 Repository Responsibilities:**
- **Business Logic** - Asset-specific query parameters
- **Relationship Mapping** - Define what related data to fetch
- **Error Transformation** - Convert API errors to user-friendly messages
- **QR Code Generation** - Asset-specific features
- **Data Validation** - Business rule enforcement
### **Step 5: Base Repository Handles HTTP Communication**
**File:** `/frontend/src/repositories/BaseRepository.js` → Lines 10-18
```javascript
async getAll(params = {}) {
try {
// 🌐 Make HTTP request to Directus API
const response = await this.api.get(`/items/${this.collection}`, {
params, // Query parameters (fields, filters, pagination)
});
return response.data; // Return Directus response
} catch (error) {
throw this.handleError(error);
}
}
```
**🎯 Base Repository Responsibilities:**
- **HTTP Communication** - Axios-based API calls
- **URL Construction** - Build REST endpoints (`/items/assets`)
- **Parameter Serialization** - Convert objects to query strings
- **Response Handling** - Extract data from HTTP responses
- **Generic CRUD** - Reusable create, read, update, delete operations
### **Step 6: Directus Service Manages Authentication**
**File:** `/frontend/src/services/directus.js` → Lines 15-27
```javascript
// Request interceptor adds auth token
directusApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('directus_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`; // 🔐 Add auth
}
return config;
}
);
```
**🎯 Directus Service Responsibilities:**
- **Authentication** - Add Bearer tokens to requests
- **Token Refresh** - Automatic token renewal
- **Base URL Configuration** - API endpoint setup
- **Request/Response Interceptors** - Global middleware
- **Development Auto-auth** - Seamless development experience
### **Step 7: HTTP Request to Directus API**
```http
GET http://localhost:8055/items/assets?fields=*,category_id.id,category_id.name,category_id.color,location_id.id,location_id.name,location_id.building,location_id.floor,vendor_id.id,vendor_id.name
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### **Step 8: Directus Queries PostgreSQL Database**
**Database Schema:** From `/schema/init.sql`
```sql
-- Directus executes this SQL query
SELECT
a.*,
c.id as "category_id.id",
c.name as "category_id.name",
c.color as "category_id.color",
l.id as "location_id.id",
l.name as "location_id.name",
l.building as "location_id.building",
l.floor as "location_id.floor",
v.id as "vendor_id.id",
v.name as "vendor_id.name"
FROM assets a
LEFT JOIN asset_categories c ON a.category_id = c.id
LEFT JOIN locations l ON a.location_id = l.id
LEFT JOIN vendors v ON a.vendor_id = v.id
WHERE a.organization_id = '70562576-ac3e-4d84-b3a6-6d0d52b8cc10';
```
### **Step 9: Database Returns Raw Data**
```json
{
"data": [
{
"id": "cd89112d-510d-4f02-acb2-bdcd6242cd2d",
"name": "Frontend Test Laptop",
"asset_identifier": "TEST-FRONTEND-1752412612",
"status": "active",
"acquisition_cost": "1200.00",
"category_id": {
"id": "4a607979-0b7c-4f04-b42a-f78616922312",
"name": "Office Furniture",
"color": "#4CAF50"
},
"location_id": {
"id": "0ead8a26-b691-45a5-be55-2ad5143f078e",
"name": "Conference Room",
"building": "Building A"
}
}
]
}
```
### **Step 10: Data Flows Back Through the Chain**
```javascript
// BaseRepository receives HTTP response
response.data ← HTTP Response
// AssetRepository receives structured data
return response.data ← BaseRepository
// Store receives and processes data
this.assets = response.data || [] ← AssetRepository
// Vue reactive system triggers re-render
assets.value ← Store (Pinia reactive state)
```
### **Step 11: Assets View Processes Data for Display**
**File:** `/frontend/src/views/Assets.vue` → Lines 333-362
```javascript
const displayedAssets = computed(() => {
// 🔄 Transform raw data for UI display
let filtered = assets.value.map(asset => ({
...asset,
// Extract nested relationship data for display
category: asset.category_id?.name || 'Unknown',
category_color: asset.category_id?.color || '#9E9E9E',
location: asset.location_id ?
`${asset.location_id.name}${asset.location_id.building ? ` - ${asset.location_id.building}` : ''}` :
'Unknown'
}));
// Apply filters (search, category, status)
if (search.value) {
const searchTerm = search.value.toLowerCase();
filtered = filtered.filter(asset =>
asset.name.toLowerCase().includes(searchTerm) ||
asset.asset_identifier.toLowerCase().includes(searchTerm)
);
}
return filtered;
});
```
### **Step 12: Vue Renders the Data**
**File:** `/frontend/src/views/Assets.vue` → Lines 104-113
```vue
<!-- Grid View -->
<v-row v-if="viewMode === 'grid'">
<v-col
v-for="asset in displayedAssets" <!-- 🎨 Reactive data renders -->
:key="asset.id"
cols="12" sm="6" md="4" lg="3"
>
<AssetCard :asset="asset" @click="viewAsset(asset.id)" />
</v-col>
</v-row>
```
## 🎯 Role Summary of Each Layer
### **1. 📄 Views/Components (Presentation Layer)**
- **User Interface** - Display data and handle user interactions
- **Event Handling** - Respond to clicks, form submissions
- **Data Transformation** - Format data for display (dates, currency, etc.)
- **Loading States** - Show spinners, empty states, errors
### **2. 🗂️ Stores (State Management Layer)**
- **Global State** - Hold application data accessible across components
- **Reactivity** - Trigger UI updates when data changes
- **Caching** - Avoid redundant API calls
- **State Coordination** - Manage loading, error, and success states
### **3. 🏢 Repositories (Business Logic Layer)**
- **Domain Logic** - Asset-specific business rules and operations
- **Data Mapping** - Transform between API and application models
- **Relationship Handling** - Define what related data to fetch
- **Validation** - Ensure data integrity and business rules
### **4. ⚡ Base Repository (Infrastructure Layer)**
- **HTTP Communication** - Generic REST API operations
- **Error Handling** - Convert HTTP errors to application errors
- **Request Configuration** - Headers, timeouts, interceptors
- **Response Processing** - Extract data from HTTP responses
### **5. 🔐 Services (Cross-Cutting Concerns)**
- **Authentication** - Token management and security
- **Caching** - Performance optimization
- **Permissions** - Access control
- **Configuration** - Environment-specific settings
## 📁 File Structure Reference
```
enterprise-asset-management/
├── frontend/
│ ├── src/
│ │ ├── views/
│ │ │ ├── Assets.vue # 📄 Presentation Layer
│ │ │ ├── AddAsset.vue
│ │ │ └── EditAsset.vue
│ │ ├── stores/
│ │ │ ├── assets.js # 🗂️ State Management
│ │ │ ├── ui.js
│ │ │ └── auth.js
│ │ ├── repositories/
│ │ │ ├── BaseRepository.js # ⚡ Infrastructure Layer
│ │ │ ├── AssetRepository.js # 🏢 Business Logic
│ │ │ └── AuthRepository.js
│ │ ├── services/
│ │ │ ├── directus.js # 🔐 API Service
│ │ │ ├── auth.js # 🔐 Auth Service
│ │ │ ├── permissions.js # 🔐 Permission Service
│ │ │ └── cache.js # 🔐 Cache Service
│ │ └── components/
│ │ └── assets/
│ │ ├── AssetForm.vue # 📄 Reusable Component
│ │ └── AssetCard.vue
├── schema/
│ └── init.sql # 🗄️ Database Schema
└── scripts/
├── setup-permissions-enhanced.sh # 🔧 Permission Setup
└── verify-permissions.sh # 🔧 Permission Verification
```
## 🔄 Data Flow Patterns
### **Create Asset Flow**
```
AssetForm → emit('submit') → AddAsset.vue → assetsStore.createAsset() →
AssetRepository.create() → BaseRepository.create() → POST /items/assets →
Database INSERT → Success Response → Cache Invalidation → UI Update
```
### **Update Asset Flow**
```
AssetForm → emit('submit') → EditAsset.vue → assetsStore.updateAsset() →
AssetRepository.update() → BaseRepository.update() → PATCH /items/assets/{id} →
Database UPDATE → Success Response → Cache Invalidation → UI Update
```
### **Authentication Flow**
```
View → authService.ensureAuthenticated() → Check localStorage token →
If expired: authService.refreshToken() → directusApi interceptor →
Add Bearer token to request headers → Continue with API call
```
This architecture provides **separation of concerns**, **testability**, **maintainability**, and **scalability** for the asset management application!

131
BACKEND_SETUP.md Normal file
View File

@ -0,0 +1,131 @@
# Backend Setup Complete! 🎉
## ✅ What's Working
### Services Running
- **PostgreSQL Database**: localhost:5432
- **Directus API/Admin**: http://localhost:8055
- **Redis Cache**: localhost:6379
- **Frontend**: http://localhost:5173 (your dev server)
### Database & Collections
- ✅ Database schema imported from `schema/init.sql`
- ✅ All tables created successfully
- ✅ Directus collections configured
- ✅ Sample data loaded:
- 1 Organization (Demo Organization)
- 4 Asset Categories (IT Equipment, Office Furniture, etc.)
- 4 Locations (Main Office, IT Department, etc.)
- 3 Vendors (Dell Technologies, Office Solutions, etc.)
### Authentication
- **Admin Email**: `admin@assetmanagement.com`
- **Admin Password**: `AssetAdmin2024!`
## 🧪 Testing the Connection
### Option 1: Frontend Testing
1. Open your frontend at http://localhost:5173
2. Try logging in with the admin credentials above
3. Navigate to "Add Asset" and create a test asset
4. Check if the asset appears in the Assets list
### Option 2: Directus Admin Testing
1. Visit http://localhost:8055/admin
2. Login with admin credentials
3. Browse the collections (Organizations, Assets, etc.)
4. Create test data directly in Directus
5. Check if it appears in your frontend
### Option 3: API Testing
```bash
# Run our test script
./scripts/test-frontend-connection.sh
```
## 🔧 Available Commands
```bash
# Start all services
make up
# Check service status
make status
# View logs
make logs
# Stop services
make down
# Reset database (if needed)
make db-reset
# Setup collections (already done)
./scripts/setup-directus-collections.sh
```
## 📊 Database Schema Highlights
### Main Tables
- **organizations**: SaaS tenant management
- **assets**: Core asset registry
- **asset_categories**: Asset classification
- **locations**: Physical asset locations
- **vendors**: Supplier management
- **work_orders**: Maintenance requests
- **asset_reminders**: Maintenance scheduling
### Key Features
- Multi-tenant (organization-based)
- Full asset lifecycle tracking
- QR code generation
- Work order management
- Financial tracking (depreciation, costs)
- Component-level tracking
## 🐛 Troubleshooting
### Frontend Can't Connect
1. Check if Directus is running: `curl http://localhost:8055/server/health`
2. Verify .env file has correct API URL
3. Check browser network tab for CORS errors
### Authentication Issues
1. Verify admin credentials in docker-compose.yml
2. Check Directus logs: `docker-compose logs directus`
3. Reset admin user if needed
### Missing Collections
1. Run: `./scripts/setup-directus-collections.sh`
2. Check Directus admin panel for collections
3. Verify database tables exist
### No Sample Data
1. Run: `make db-reset` (will recreate everything)
2. Check init.sql execution in container logs
## 🚀 Next Steps
1. **Test Asset Creation**: Use the frontend Add Asset form
2. **Configure Permissions**: Set up user roles in Directus admin
3. **Customize Fields**: Add custom fields via Directus admin
4. **Setup Relations**: Configure foreign key relationships
5. **Deploy**: Configure for production environment
## 📁 Project Structure
```
enterprise-asset-management/
├── frontend/ # Vue.js application
│ ├── src/
│ │ ├── repositories/ # API communication
│ │ ├── stores/ # State management
│ │ └── views/ # Pages (Dashboard, AddAsset, etc.)
│ └── .env # Frontend configuration
├── schema/
│ └── init.sql # Database schema & sample data
├── scripts/ # Setup and utility scripts
└── docker-compose.yml # Service orchestration
```
Your enterprise asset management system is now fully operational! 🎯

95
MIGRATION_CLEANUP.md Normal file
View File

@ -0,0 +1,95 @@
# Migration Cleanup Report: Directus to Node API
## Summary
This document tracks the cleanup performed after migrating asset operations from Directus API to the custom Node.js API.
## Files Removed ✅
### 1. `/frontend/src/repositories/AssetRepository.js`
- **Reason**: Replaced by `NodeAssetRepository.js`
- **Status**: ✅ Safely removed
- **Dependencies**: Extended `BaseRepository.js` (also removed)
### 2. `/frontend/src/repositories/BaseRepository.js`
- **Reason**: Directus-specific base class, no longer needed
- **Status**: ✅ Safely removed
- **Dependencies**: Used `directusApi` service
### 3. Temporary Debug Files
- `/node_api/debug.js` - ✅ Removed
- `/node_api/test-update.js` - ✅ Removed
## Files Modified 🔧
### 1. `/frontend/src/services/cache.js`
- **Change**: Removed Directus server cache clearing functionality
- **Reason**: node_api doesn't use Directus caching
- **Status**: ✅ Updated to remove `directusApi` dependency
### 2. `/frontend/src/stores/assets.js`
- **Change**: Updated to use `NodeAssetRepository` instead of `AssetRepository`
- **Status**: ✅ Already updated during migration
## Files Kept (Still in Use) ❌
### Authentication & Core Services
- `/frontend/src/services/directus.js` - Still needed for auth, files, permissions
- `/frontend/src/services/auth.js` - Authentication still uses Directus
- `/frontend/src/services/fileUpload.js` - File operations still use Directus
- `/frontend/src/services/permissions.js` - Permissions still use Directus
- `/frontend/src/repositories/AuthRepository.js` - Authentication repository
### Cache Service
- `/frontend/src/services/cache.js` - Local caching still valuable (modified to remove Directus server cache)
## Migration Status
### ✅ Successfully Migrated to Node API
- Asset CRUD operations (create, read, update, delete)
- Asset data consistency and caching issues resolved
- Direct PostgreSQL queries bypass Directus caching problems
### ❌ Still Using Directus (Future Migration)
- User authentication and session management
- File upload and image storage
- User permissions and role management
- Reference data (categories, locations, vendors)
## Architecture Overview
```
Frontend Components
├── Asset Operations → Node API (Port 3001)
├── Authentication → Directus API (Port 8055)
├── File Uploads → Directus API (Port 8055)
├── Permissions → Directus API (Port 8055)
└── Reference Data → Directus API (Port 8055)
```
## Benefits Achieved
1. **Data Consistency**: Eliminated caching issues with asset operations
2. **Performance**: Direct PostgreSQL queries for asset data
3. **Immediate UI Updates**: Optimistic updates work reliably
4. **Maintainability**: Simplified asset data flow
5. **Security**: Maintained Directus authentication and permissions
## Next Steps (Future Enhancements)
1. **Authentication Migration**: Move auth system to node_api
2. **File Upload Migration**: Implement file handling in node_api
3. **Reference Data Migration**: Move categories/locations to node_api
4. **Permissions Migration**: Implement custom permission system
5. **Complete Directus Removal**: Once all services migrated
## Technical Notes
- Node API uses JWT token validation against Directus for security
- Database schema mappings properly handled in `NodeAssetRepository`
- CORS configured for both development ports (3000, 5173)
- All asset forms and views compatible with new API structure
---
**Migration Completed**: July 14, 2025
**Status**: Partial (Asset operations complete, authentication pending)

135
Makefile Normal file
View File

@ -0,0 +1,135 @@
# Makefile for Enterprise Asset Management System
.PHONY: help install dev build prod clean logs stop restart schema
# Default target
help:
@echo "Enterprise Asset Management System - Available Commands:"
@echo ""
@echo "Quick Start:"
@echo " make quick-start - Complete setup for new developers"
@echo " make setup - Full development environment setup"
@echo ""
@echo "Development:"
@echo " make install - Install frontend and node_api dependencies"
@echo " make dev - Start development environment"
@echo " make dev-fe - Start frontend development server"
@echo " make dev-api - Start node API development server"
@echo " make schema - Import database schema and sample data"
@echo ""
@echo "Production:"
@echo " make build - Build frontend for production"
@echo " make prod - Build and run production environment"
@echo ""
@echo "Docker Management:"
@echo " make up - Start all services"
@echo " make down - Stop all services"
@echo " make restart - Restart all services"
@echo " make logs - Show service logs"
@echo " make clean - Clean up containers and volumes"
@echo ""
@echo "Database:"
@echo " make db-reset - Reset database with fresh schema"
@echo " make backup - Create database backup"
# Installation
install:
@echo "📦 Installing frontend dependencies..."
cd frontend && npm install
@echo "📦 Installing node_api dependencies..."
cd node_api && npm install
# Development
dev:
@echo "🚀 Starting development environment..."
@if [ ! -f frontend/.env ]; then \
echo "📝 Creating environment file..."; \
cp frontend/.env.example frontend/.env; \
echo "⚠️ Please update frontend/.env with your configuration"; \
fi
docker-compose up postgres directus redis -d
@echo "⏳ Waiting for backend services..."
@sleep 20
@echo "✅ Backend ready! Import schema with: make schema"
@echo "Then start frontend with: make dev-fe"
dev-fe:
@echo "🎯 Starting frontend development server..."
cd frontend && npm run dev
dev-api:
@echo "🎯 Starting node API development server..."
cd node_api && npm run dev
# Schema setup
schema:
@echo "🗃️ Setting up database schema..."
@echo "Schema will be automatically loaded from schema/init.sql"
@echo "Includes: tables, relationships, sample data, and permissions"
@echo "✅ Schema import completed!"
@echo "🔗 Directus Admin: http://localhost:8055/admin"
@echo "🔑 Admin Login: admin@assetmanagement.com / AssetAdmin2024!"
@echo "📋 Full permissions configured for Administrator role"
# Building
build:
@echo "🔨 Building frontend..."
cd frontend && npm run build
# Production
prod:
@echo "🚀 Starting production environment..."
docker-compose down
docker-compose up --build -d
@echo "⏳ Waiting for services..."
@sleep 30
@echo "✅ Production environment ready!"
@echo "🔗 Frontend: http://localhost:3000"
@echo "🔗 Directus Admin: http://localhost:8055/admin"
# Docker management
up:
docker-compose up -d
down:
docker-compose down
restart:
docker-compose restart
logs:
docker-compose logs -f
clean:
@echo "🧹 Cleaning up..."
docker-compose down -v
docker system prune -f
@echo "✅ Cleanup complete!"
# Database management
db-reset:
@echo "🗃️ Resetting database..."
docker-compose down
docker volume rm enterprise-asset-management_postgres_data || true
docker-compose up postgres directus redis -d
@echo "⏳ Waiting for services..."
@sleep 25
make schema
@echo "✅ Database reset complete!"
# Quick start for new developers
quick-start:
@echo "🚀 Quick start for new developers..."
make install
make dev
make schema
@echo ""
@echo "🎯 Ready to develop!"
@echo "Run 'make dev-fe' to start the frontend development server"
# Show system status
status:
@echo "📊 System Status:"
@echo ""
@echo "Docker Containers:"
@docker-compose ps

0
README.md Normal file
View File

168
docker-compose.yml Normal file
View File

@ -0,0 +1,168 @@
# docker-compose.yml
version: '3.8'
services:
# PostgreSQL Database
postgres:
image: postgres:15
container_name: asset_mgmt_db
environment:
POSTGRES_DB: asset_management
POSTGRES_USER: directus
POSTGRES_PASSWORD: directus123
volumes:
- postgres_data:/var/lib/postgresql/data
- ./schema/init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- '5432:5432'
networks:
- asset_network
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U directus']
interval: 30s
timeout: 10s
retries: 3
# Redis for caching and sessions
redis:
image: redis:7-alpine
container_name: asset_mgmt_redis
volumes:
- redis_data:/data
ports:
- '6379:6379'
networks:
- asset_network
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 30s
timeout: 10s
retries: 3
# Directus Backend
directus:
image: directus/directus:11.5.1
container_name: asset_mgmt_backend
ports:
- '8055:8055'
volumes:
- directus_uploads:/directus/uploads
- directus_extensions:/directus/extensions
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
KEY: 'asset-mgmt-random-key-change-in-production'
SECRET: 'asset-mgmt-random-secret-change-in-production'
# Database
DB_CLIENT: 'pg'
DB_HOST: 'postgres'
DB_PORT: '5432'
DB_DATABASE: 'asset_management'
DB_USER: 'directus'
DB_PASSWORD: 'directus123'
# Cache & Session
CACHE_ENABLED: 'true'
CACHE_STORE: 'redis'
REDIS: 'redis://redis:6379'
SESSION_STORE: 'redis'
SESSION_REDIS: 'redis://redis:6379'
# CORS
CORS_ENABLED: 'true'
CORS_ORIGIN: 'http://localhost:3000,http://localhost:5173'
# Admin
ADMIN_EMAIL: 'admin@assetmanagement.com'
ADMIN_PASSWORD: 'AssetAdmin2024!'
# Email (configure for production)
EMAIL_FROM: 'noreply@assetmanagement.com'
EMAIL_TRANSPORT: 'smtp'
EMAIL_SMTP_HOST: 'smtp.gmail.com'
EMAIL_SMTP_PORT: '587'
EMAIL_SMTP_USER: ''
EMAIL_SMTP_PASSWORD: ''
# Rate Limiting
RATE_LIMITER_ENABLED: 'true'
RATE_LIMITER_STORE: 'redis'
RATE_LIMITER_REDIS: 'redis://redis:6379'
networks:
- asset_network
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8055/server/health']
interval: 30s
timeout: 10s
retries: 3
# Custom Node API
node_api:
build:
context: ./node_api
dockerfile: Dockerfile
container_name: asset_mgmt_node_api
ports:
- '3001:3001'
depends_on:
postgres:
condition: service_healthy
directus:
condition: service_healthy
networks:
- asset_network
environment:
NODE_ENV: production
PORT: 3001
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: asset_management
DB_USER: directus
DB_PASSWORD: directus123
DIRECTUS_URL: http://directus:8055
CORS_ORIGIN: http://localhost:3000,http://localhost:5173
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX_REQUESTS: 100
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3001/health']
interval: 30s
timeout: 10s
retries: 3
# Frontend (Vue 3 + Vuetify) - Will be built later
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
container_name: asset_mgmt_frontend
ports:
- '3000:80'
depends_on:
directus:
condition: service_healthy
node_api:
condition: service_healthy
networks:
- asset_network
environment:
- NODE_ENV=production
volumes:
postgres_data:
driver: local
redis_data:
driver: local
directus_uploads:
driver: local
directus_extensions:
driver: local
networks:
asset_network:
driver: bridge

18
frontend/.env.example Normal file
View File

@ -0,0 +1,18 @@
# Frontend Environment Variables
VITE_API_URL=http://localhost:8055
VITE_NODE_API_URL=http://localhost:3001
VITE_APP_TITLE=Enterprise Asset Management
VITE_APP_VERSION=1.0.0
# Payment Integration (Configure for production)
VITE_STRIPE_PUBLIC_KEY=pk_test_your_stripe_public_key
VITE_PAYPAL_CLIENT_ID=your_paypal_client_id
# Development Settings
VITE_DEBUG=true
VITE_LOG_LEVEL=debug
# Production Settings (uncomment for production)
# VITE_API_URL=https://your-api-domain.com
# VITE_DEBUG=false
# VITE_LOG_LEVEL=error

19
frontend/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enterprise Asset Management</title>
<meta name="description" content="Professional asset management and tracking system">
<link rel="icon" href="/favicon.ico">
<!-- Preload fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

9186
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "enterprise-asset-management",
"version": "1.0.0",
"description": "Enterprise Asset Management SaaS Platform",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@mdi/font": "^7.2.0",
"@vueuse/core": "^10.5.0",
"@vueuse/integrations": "^10.5.0",
"axios": "^1.6.0",
"chart.js": "^4.4.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.0",
"file-saver": "^2.0.0",
"html2canvas": "^1.4.0",
"jspdf": "^2.5.0",
"lodash-es": "^4.17.0",
"mime-types": "^2.1.0",
"pinia": "^2.1.0",
"pinia-plugin-persistedstate": "^3.2.0",
"qrcode": "^1.5.0",
"qrcode-reader": "^1.0.0",
"uuid": "^9.0.0",
"vue": "^3.4.0",
"vue-chartjs": "^5.3.0",
"vue-i18n": "^9.6.0",
"vue-pdf-embed": "^1.2.0",
"vue-qrcode-reader": "^5.4.0",
"vue-router": "^4.2.0",
"vuetify": "^3.4.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.0",
"sass": "^1.69.0",
"vite": "^5.4.19",
"vite-plugin-pwa": "^0.17.0",
"vite-plugin-vuetify": "^2.1.1"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
}
}

114
frontend/src/App.vue Normal file
View File

@ -0,0 +1,114 @@
<!-- frontend/src/App.vue -->
<template>
<v-app>
<!-- Header for authenticated users -->
<AppHeader v-if="isAuthenticated" @toggle-drawer="toggleDrawer" />
<!-- Sidebar Navigation -->
<AppSidebar v-if="isAuthenticated" v-model="drawer" />
<!-- Main Content Area -->
<v-main>
<v-container fluid>
<!-- Loading overlay -->
<div v-if="isLoading" class="loading-overlay">
<v-progress-circular indeterminate size="64" color="primary" />
<h1>The App is displaying content</h1>
</div>
<!-- Router view for page content -->
<router-view />
</v-container>
</v-main>
<!-- Footer for authenticated users -->
<AppFooter v-if="isAuthenticated" />
<!-- Global snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top right"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false"> Close </v-btn>
</template>
</v-snackbar>
</v-app>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useUIStore } from './stores/ui';
import AppHeader from './components/common/AppHeader.vue';
import AppSidebar from './components/common/AppSidebar.vue';
import AppFooter from './components/common/AppFooter.vue';
export default {
name: 'App',
components: {
AppHeader,
AppSidebar,
AppFooter,
},
setup() {
const authStore = useAuthStore();
const uiStore = useUIStore();
const drawer = computed({
get: () => uiStore.drawer,
set: (value) => uiStore.setDrawer(value),
});
const isAuthenticated = computed(() => authStore.isAuthenticated);
const isLoading = computed(() => uiStore.isLoading);
const snackbar = computed(() => uiStore.snackbar);
const toggleDrawer = () => {
if (uiStore.sidebarPersistent) {
uiStore.toggleSidebarCollapsed();
} else {
uiStore.toggleDrawer();
}
};
onMounted(async () => {
// Check if user is already authenticated
if (authStore.token) {
try {
await authStore.fetchUser();
} catch (error) {
console.error('Failed to fetch user:', error);
authStore.logout();
}
}
});
return {
drawer,
isAuthenticated,
isLoading,
snackbar,
toggleDrawer,
};
},
};
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
</style>

View File

@ -0,0 +1,552 @@
// frontend/src/assets/styles/main.scss
// Global styles - shadcn/ui inspired theme
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background-color: #FFFFFF;
font-size: 14px;
line-height: 1.5;
color: #0F172A;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Custom Vuetify theme overrides for shadcn/ui style
.v-application {
font-family: 'Inter', 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif !important;
background-color: #FFFFFF !important;
}
// shadcn/ui typography scale
.text-hero {
font-size: 36px;
font-weight: 800;
line-height: 40px;
letter-spacing: -0.02em;
color: #0F172A;
}
.text-title1 {
font-size: 30px;
font-weight: 600;
line-height: 36px;
letter-spacing: -0.02em;
color: #0F172A;
}
.text-title2 {
font-size: 24px;
font-weight: 600;
line-height: 32px;
letter-spacing: -0.01em;
color: #0F172A;
}
.text-title3 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: #0F172A;
}
.text-body1 {
font-size: 16px;
font-weight: 400;
line-height: 24px;
color: #475569;
}
.text-body2 {
font-size: 14px;
font-weight: 400;
line-height: 20px;
color: #475569;
}
.text-caption1 {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: #64748B;
}
.text-caption2 {
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: #64748B;
}
// shadcn/ui style animations
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Page entrance animation
.fluent-entrance {
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
// shadcn/ui style cards
.v-card {
border: 1px solid #E2E8F0;
border-radius: 8px;
box-shadow: none;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
background: #FFFFFF;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border-color: #CBD5E1;
}
&.elevated {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
}
// shadcn/ui style data tables
.v-data-table {
border: 1px solid #E2E8F0;
border-radius: 8px;
background: #FFFFFF;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
.v-data-table-header {
background: #F8FAFC;
border-bottom: 1px solid #E2E8F0;
th {
font-weight: 500;
color: #475569;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 12px 16px;
border-right: none;
&:hover {
background-color: #F1F5F9;
}
}
}
.v-data-table__td {
padding: 12px 16px;
border-bottom: 1px solid #F1F5F9;
border-right: none;
font-size: 14px;
color: #475569;
}
.v-data-table__tr {
transition: background-color 0.15s ease;
&:hover {
background: #F8FAFC;
}
&:last-child .v-data-table__td {
border-bottom: none;
}
}
}
// shadcn/ui style form inputs
.v-form {
.v-text-field, .v-select, .v-textarea {
margin-bottom: 16px;
:deep(.v-field) {
border: 1px solid #E2E8F0;
border-radius: 6px;
background-color: #FFFFFF;
transition: all 0.2s ease;
&:hover {
border-color: #CBD5E1;
}
&.v-field--focused {
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&.v-field--error {
border-color: #EF4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
:deep(.v-field__input) {
padding: 8px 12px;
font-size: 14px;
line-height: 20px;
color: #0F172A;
min-height: 40px;
&::placeholder {
color: #94A3B8;
}
}
:deep(.v-field__outline) {
display: none;
}
:deep(.v-label) {
font-size: 14px;
font-weight: 500;
color: #374151;
}
}
}
// shadcn/ui style buttons
.v-btn {
border-radius: 6px;
font-weight: 500;
text-transform: none;
font-size: 14px;
line-height: 20px;
transition: all 0.2s ease;
box-shadow: none;
letter-spacing: 0;
&.v-btn--variant-elevated {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
}
&.v-btn--variant-outlined {
border: 1px solid #E2E8F0;
background: #FFFFFF;
&:hover {
border-color: #CBD5E1;
background: #F8FAFC;
}
}
&.v-btn--variant-text {
&:hover {
background-color: #F1F5F9;
}
}
// Primary button
&.bg-primary {
background: #0F172A;
color: #F8FAFC;
&:hover {
background: #1E293B;
}
}
// Secondary button
&.bg-secondary {
background: #F1F5F9;
color: #0F172A;
&:hover {
background: #E2E8F0;
}
}
// Destructive button
&.bg-destructive {
background: #EF4444;
color: #FFFFFF;
&:hover {
background: #DC2626;
}
}
}
// shadcn/ui style chips/badges
.v-chip {
border-radius: 6px;
font-weight: 500;
font-size: 12px;
line-height: 16px;
padding: 2px 8px;
height: auto;
min-height: 20px;
border: 1px solid #E2E8F0;
&.v-chip--variant-flat {
background: #F1F5F9;
color: #475569;
}
// Status specific styles
&.text-status-active {
background: #DCFCE7;
color: #166534;
border-color: #BBF7D0;
}
&.text-status-inactive {
background: #F1F5F9;
color: #64748B;
border-color: #E2E8F0;
}
&.text-status-maintenance {
background: #FEF3C7;
color: #92400E;
border-color: #FDE68A;
}
&.text-destructive {
background: #FEE2E2;
color: #991B1B;
border-color: #FECACA;
}
}
// shadcn/ui style alerts
.v-alert {
border-radius: 8px;
border: 1px solid;
background: #FFFFFF;
box-shadow: none;
&.v-alert--type-info {
background: #EFF6FF;
border-color: #DBEAFE;
color: #1E40AF;
}
&.v-alert--type-success {
background: #F0FDF4;
border-color: #BBF7D0;
color: #166534;
}
&.v-alert--type-warning {
background: #FFFBEB;
border-color: #FDE68A;
color: #92400E;
}
&.v-alert--type-error {
background: #FEF2F2;
border-color: #FECACA;
color: #991B1B;
}
}
// Asset card styles with shadcn/ui aesthetic
.asset-card {
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid #E2E8F0;
border-radius: 8px;
background: #FFFFFF;
&:hover {
border-color: #CBD5E1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
&:focus-within {
border-color: #3B82F6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
// Dashboard stat cards
.stat-card {
transition: all 0.2s ease;
border: 1px solid #E2E8F0;
border-radius: 8px;
background: #FFFFFF;
&:hover {
border-color: #CBD5E1;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
}
// Page header styles
.page-header {
margin-bottom: 24px;
h1 {
color: #0F172A;
margin-bottom: 4px;
}
p {
color: #64748B;
margin: 0;
}
}
// Form sections
.form-section {
border-bottom: 1px solid #E2E8F0;
padding-bottom: 24px;
margin-bottom: 24px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
h2 {
color: #0F172A;
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
}
}
// Navigation styles
.v-app-bar {
border-bottom: 1px solid #E2E8F0;
background: #FFFFFF;
.v-toolbar__title {
color: #0F172A;
font-weight: 600;
}
}
.v-navigation-drawer {
border-right: 1px solid #E2E8F0;
background: #FFFFFF;
}
// Loading states
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(2px);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
// Utility classes
.text-muted {
color: #64748B;
}
.text-sm {
font-size: 14px;
line-height: 20px;
}
.text-xs {
font-size: 12px;
line-height: 16px;
}
.border {
border: 1px solid #E2E8F0;
}
.border-dashed {
border-style: dashed;
}
.rounded-lg {
border-radius: 8px;
}
.rounded-md {
border-radius: 6px;
}
.shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
// Responsive design
@media (max-width: 768px) {
.text-title1 {
font-size: 24px;
line-height: 32px;
}
.text-title2 {
font-size: 20px;
line-height: 28px;
}
.form-section {
padding-bottom: 16px;
margin-bottom: 16px;
}
.asset-card:hover {
transform: none;
}
}
// Dark mode support (if needed)
@media (prefers-color-scheme: dark) {
.v-theme--dark {
.v-card {
border-color: #1E293B;
}
.v-data-table {
border-color: #1E293B;
.v-data-table-header {
background: #1E293B;
border-bottom-color: #334155;
}
.v-data-table__td {
border-bottom-color: #1E293B;
}
}
}
}

View File

View File

@ -0,0 +1,61 @@
// frontend/src/assets/styles/variables.scss
// Brand Colors
$primary-color: #1976D2;
$secondary-color: #424242;
$accent-color: #1976D2;
$error-color: #F44336;
$warning-color: #FF9800;
$info-color: #2196F3;
$success-color: #4CAF50;
// Status Colors
$status-active: #4CAF50;
$status-inactive: #9E9E9E;
$status-maintenance: #FF9800;
$status-retired: #795548;
$status-disposed: #F44336;
// Priority Colors
$priority-low: #81C784;
$priority-medium: #FFB74D;
$priority-high: #FF8A65;
$priority-urgent: #E57373;
$priority-emergency: #F44336;
// Layout
$header-height: 64px;
$sidebar-width: 280px;
$footer-height: 48px;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
// Border Radius
$border-radius-sm: 4px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
// Shadows
$shadow-light: 0 2px 4px rgba(0,0,0,0.1);
$shadow-medium: 0 4px 8px rgba(0,0,0,0.15);
$shadow-heavy: 0 8px 16px rgba(0,0,0,0.2);
// Typography
$font-family: 'Roboto', sans-serif;
$font-size-sm: 12px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-2xl: 24px;
$font-size-3xl: 32px;
// Z-index
$z-header: 1000;
$z-sidebar: 999;
$z-overlay: 9999;
$z-modal: 10000;

View File

@ -0,0 +1,254 @@
<!-- frontend/src/components/assets/AssetCard.vue -->
<template>
<v-card class="asset-card" elevation="2" @click="$emit('click')">
<!-- Asset image or icon -->
<div class="asset-image-container">
<v-img v-if="assetImageUrl" :src="assetImageUrl" height="160" cover />
<div v-else class="default-image d-flex align-center justify-center">
<v-icon size="64" :color="asset.category_color || 'primary'">
{{ getCategoryIcon(asset.category) }}
</v-icon>
</div>
<!-- Status badge -->
<v-chip
:color="getStatusColor(asset.status)"
size="small"
variant="flat"
class="status-badge"
>
{{ asset.status }}
</v-chip>
</div>
<!-- Card content -->
<v-card-text class="pb-2">
<!-- Asset name and identifier -->
<div class="mb-2">
<h3 class="text-h6 font-weight-medium mb-1 text-truncate">
{{ asset.name }}
</h3>
<p class="text-caption text-grey-darken-1 mb-0">
{{ asset.asset_identifier }}
</p>
</div>
<!-- Category and location -->
<div class="mb-3">
<v-chip
:color="asset.category_color || 'primary'"
size="small"
variant="tonal"
class="mr-2 mb-1"
>
{{ asset.category }}
</v-chip>
<div class="text-caption text-grey-darken-1 mt-1">
<v-icon size="12" class="mr-1">mdi-map-marker</v-icon>
{{ asset.location }}
</div>
</div>
<!-- Asset details -->
<div class="asset-details">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-caption text-grey-darken-1">Value:</span>
<span class="text-caption font-weight-medium">
{{ formatCurrency(asset.acquisition_cost) }}
</span>
</div>
<div class="d-flex justify-space-between align-center">
<span class="text-caption text-grey-darken-1">Added:</span>
<span class="text-caption font-weight-medium">
{{ formatDate(asset.acquisition_date) }}
</span>
</div>
</div>
</v-card-text>
<!-- Card actions -->
<v-card-actions class="pt-0">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-eye"
@click.stop="$emit('view', asset)"
>
View
</v-btn>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-qrcode"
@click.stop="$emit('qr-code', asset)"
>
QR
</v-btn>
<v-spacer />
<v-menu>
<template #activator="{ props }">
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
v-bind="props"
@click.stop
/>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-pencil"
title="Edit"
@click="$emit('edit', asset)"
/>
<v-list-item
prepend-icon="mdi-content-duplicate"
title="Duplicate"
@click="$emit('duplicate', asset)"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-delete"
title="Delete"
class="text-error"
@click="$emit('delete', asset)"
/>
</v-list>
</v-menu>
</v-card-actions>
</v-card>
</template>
<script>
import { computed } from 'vue';
import { fileUploadService } from '../../services/fileUpload';
export default {
name: 'AssetCard',
props: {
asset: {
type: Object,
required: true,
},
},
emits: ['click', 'view', 'edit', 'delete', 'duplicate', 'qr-code'],
setup(props) {
// Compute the asset image URL
const assetImageUrl = computed(() => {
if (!props.asset.image_url) return null;
// Handle both nested object format and direct ID
const fileId = typeof props.asset.image_url === 'object'
? props.asset.image_url.id
: props.asset.image_url;
return fileId ? fileUploadService.getImageUrl(fileId, {
width: 320,
height: 160,
fit: 'cover',
quality: 85
}) : null;
});
const getCategoryIcon = (category) => {
const icons = {
'IT Equipment': 'mdi-laptop',
'Office Furniture': 'mdi-desk',
'Medical Equipment': 'mdi-medical-bag',
Vehicles: 'mdi-car',
'Manufacturing Equipment': 'mdi-factory',
};
return icons[category] || 'mdi-package-variant';
};
const getStatusColor = (status) => {
const colors = {
active: 'success',
inactive: 'default',
maintenance: 'warning',
retired: 'error',
disposed: 'error',
};
return colors[status] || 'default';
};
const formatCurrency = (amount) => {
if (!amount) return '-';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const formatDate = (date) => {
if (!date) return '-';
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(date));
};
return {
assetImageUrl,
getCategoryIcon,
getStatusColor,
formatCurrency,
formatDate,
};
},
};
</script>
<style scoped>
.asset-card {
cursor: pointer;
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
}
.asset-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.asset-image-container {
position: relative;
height: 160px;
}
.default-image {
height: 160px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
}
.v-card-text {
flex: 1;
}
.asset-details {
border-top: 1px solid rgba(0, 0, 0, 0.12);
padding-top: 12px;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,791 @@
<!-- frontend/src/components/assets/AssetForm.vue -->
<template>
<v-form ref="form" v-model="isFormValid" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<div class="form-section mb-8">
<h2 class="text-title3 mb-4" style="color: #323130;">
Basic Information
</h2>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.name"
label="Asset Name"
placeholder="Enter asset name"
:rules="nameRules"
required
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.assetIdentifier"
label="Asset Identifier"
placeholder="e.g., AST-001"
:rules="identifierRules"
required
class="enterprise-field"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.category"
:items="categories"
label="Category"
placeholder="Select category"
:rules="categoryRules"
required
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.status"
:items="statusOptions"
label="Status"
placeholder="Select status"
:rules="statusRules"
required
class="enterprise-field"
/>
</v-col>
</v-row>
<v-textarea
v-model="formData.description"
label="Description"
placeholder="Enter asset description"
rows="3"
class="enterprise-field"
/>
</div>
<!-- Location & Assignment Section -->
<div class="form-section mb-8">
<h2 class="text-title3 mb-4" style="color: #323130;">
Location & Assignment
</h2>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.location"
:items="locations"
label="Location"
placeholder="Select location"
:rules="locationRules"
required
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.assignedTo"
:items="users"
item-title="name"
item-value="id"
label="Assigned To"
placeholder="Select user (optional)"
clearable
class="enterprise-field"
/>
</v-col>
</v-row>
</div>
<!-- Financial Information Section -->
<div class="form-section mb-8">
<h2 class="text-title3 mb-4" style="color: #323130;">
Financial Information
</h2>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.purchasePrice"
label="Purchase Price"
placeholder="0.00"
type="number"
step="0.01"
min="0"
prefix="$"
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.currentValue"
label="Current Value"
placeholder="0.00"
type="number"
step="0.01"
min="0"
prefix="$"
class="enterprise-field"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.purchaseDate"
label="Purchase Date"
type="date"
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.warrantyExpiry"
label="Warranty Expiry"
type="date"
class="enterprise-field"
/>
</v-col>
</v-row>
</div>
<!-- Additional Details Section -->
<div class="form-section mb-8">
<h2 class="text-title3 mb-4" style="color: #323130;">
Additional Details
</h2>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.manufacturer"
label="Manufacturer"
placeholder="Enter manufacturer"
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.model"
label="Model"
placeholder="Enter model"
class="enterprise-field"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.serialNumber"
label="Serial Number"
placeholder="Enter serial number"
class="enterprise-field"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.vendor"
:items="vendors"
label="Vendor"
placeholder="Select vendor"
clearable
class="enterprise-field"
/>
</v-col>
</v-row>
</div>
<!-- Image Upload Section -->
<div class="form-section mb-8">
<h2 class="text-title3 mb-4" style="color: #323130;">
Asset Image
</h2>
<div class="image-upload-container">
<v-file-input
v-model="selectedFiles"
label="Upload Asset Image"
placeholder="Select image file"
accept="image/*"
prepend-icon="mdi-camera"
show-size
class="enterprise-field"
:loading="isUploading"
:disabled="isUploading"
/>
<!-- Upload Progress -->
<v-progress-linear
v-if="isUploading"
:model-value="uploadProgress"
color="primary"
height="4"
class="mt-2"
/>
<div v-if="isUploading" class="text-caption text-center mt-1">
Uploading... {{ uploadProgress }}%
</div>
<!-- Image Preview -->
<div v-if="imagePreview" class="image-preview mt-4">
<v-img
:src="imagePreview"
max-width="200"
max-height="150"
class="fluent-layer-1"
style="border-radius: 4px;"
/>
<v-btn
icon
size="small"
color="error"
class="remove-image-btn"
@click="removeImage"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<v-divider class="mb-6" />
<div class="d-flex justify-end gap-3">
<v-btn
variant="outlined"
size="large"
@click="handleCancel"
class="mr-3"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="elevated"
size="large"
type="submit"
:loading="isSubmitting"
:disabled="!isFormValid"
class="fluent-layer-2"
>
<v-icon left>mdi-check</v-icon>
{{ mode === 'edit' ? 'Update Asset' : 'Create Asset' }}
</v-btn>
</div>
</div>
</v-form>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue';
import { useAssetsStore } from '../../stores/assets';
import { fileUploadService } from '../../services/fileUpload';
export default {
name: 'AssetForm',
props: {
mode: {
type: String,
default: 'create', // 'create' or 'edit'
validator: (value) => ['create', 'edit'].includes(value)
},
initialData: {
type: Object,
default: () => ({})
},
isSubmitting: {
type: Boolean,
default: false
}
},
emits: ['submit', 'cancel'],
setup(props, { emit }) {
const assetsStore = useAssetsStore();
const form = ref(null);
const isFormValid = ref(false);
const selectedFiles = ref([]);
const imagePreview = ref(null);
const isUploading = ref(false);
const uploadProgress = ref(0);
// Form data
const formData = ref({
name: '',
assetIdentifier: '',
description: '',
category: '',
status: 'active',
location: '',
assignedTo: null,
purchasePrice: null,
currentValue: null,
purchaseDate: '',
warrantyExpiry: '',
manufacturer: '',
model: '',
serialNumber: '',
vendor: '',
imageFileId: null,
imageUrl: null,
imageTitle: '',
imageAltText: '',
});
// Form options
const categories = ref([]);
const locations = ref([]);
const vendors = ref([]);
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Inactive', value: 'inactive' },
{ title: 'Maintenance', value: 'maintenance' },
{ title: 'Retired', value: 'retired' },
];
const users = ref([
{ id: 1, name: 'John Smith' },
{ id: 2, name: 'Jane Doe' },
{ id: 3, name: 'Mike Johnson' },
{ id: 4, name: 'Sarah Wilson' },
]);
// Validation rules
const nameRules = [
v => !!v || 'Asset name is required',
v => (v && v.length >= 3) || 'Asset name must be at least 3 characters',
];
const identifierRules = [
v => !!v || 'Asset identifier is required',
v => (v && v.length >= 3) || 'Asset identifier must be at least 3 characters',
];
const categoryRules = [
v => !!v || 'Category is required',
];
const statusRules = [
v => !!v || 'Status is required',
];
const locationRules = [
v => !!v || 'Location is required',
];
// Methods
const handleFileUpload = async (files) => {
console.log('🎯 handleFileUpload called with:', { files, length: files?.length });
if (!files || files.length === 0) {
console.log('❌ No files provided to handleFileUpload');
return;
}
const file = files[0];
console.log('📁 Processing file:', {
name: file.name,
size: file.size,
type: file.type,
isFile: file instanceof File,
lastModified: file.lastModified
});
// Ensure we have a valid File object
if (!(file instanceof File)) {
console.error('❌ Invalid file object:', file);
return;
}
console.log('🚀 Starting upload process...');
isUploading.value = true;
uploadProgress.value = 0;
try {
// Validate file before upload
console.log('✅ Validating file...');
fileUploadService.validateImageFile(file);
console.log('✅ File validation passed');
// Show immediate preview while uploading
console.log('📸 Creating local preview...');
const reader = new FileReader();
reader.onload = (e) => {
imagePreview.value = e.target.result;
console.log('✅ Local preview created');
};
reader.readAsDataURL(file);
// Create progress tracker
const progressTracker = fileUploadService.createProgressTracker();
progressTracker.onProgress((progress) => {
uploadProgress.value = progress;
console.log(`📊 Upload progress: ${progress}%`);
});
// Upload to Directus
const assetName = formData.value.name || 'New Asset';
console.log('📤 Uploading to Directus with asset name:', assetName);
const response = await fileUploadService.uploadAssetImage(file, assetName);
console.log('✅ Directus upload response:', response);
// Store file information
console.log('💾 Storing file information in form data...');
formData.value.imageFileId = response.data.id;
formData.value.imageUrl = fileUploadService.getImageUrl(response.data.id);
formData.value.imageTitle = response.data.title || file.name;
formData.value.imageAltText = `Image of ${assetName}`;
console.log('💾 File information stored:', {
imageFileId: formData.value.imageFileId,
imageUrl: formData.value.imageUrl,
imageTitle: formData.value.imageTitle,
imageAltText: formData.value.imageAltText
});
// Update preview to use the uploaded image URL
imagePreview.value = formData.value.imageUrl;
console.log('📸 Preview updated to use uploaded URL');
console.log('✅ Image upload completed successfully!');
} catch (error) {
console.error('❌ Image upload failed:', error);
// Clear the file input and preview on error
selectedFiles.value = [];
imagePreview.value = null;
// Show error to user (assuming there's a way to show notifications)
if (typeof emit === 'function') {
emit('upload-error', error.message);
}
// You might want to show a toast notification here
alert(`Upload failed: ${error.message}`);
} finally {
console.log('🏁 Upload process finished, cleaning up...');
isUploading.value = false;
uploadProgress.value = 0;
}
};
const removeImage = async () => {
try {
// If we have an uploaded file, delete it from Directus
if (formData.value.imageFileId) {
await fileUploadService.deleteFile(formData.value.imageFileId);
console.log('🗑️ Image deleted from server');
}
} catch (error) {
console.warn('⚠️ Failed to delete image from server:', error);
// Continue with local cleanup even if server deletion fails
}
// Clear local state
selectedFiles.value = [];
imagePreview.value = null;
formData.value.imageFileId = null;
formData.value.imageUrl = null;
formData.value.imageTitle = '';
formData.value.imageAltText = '';
};
const handleSubmit = () => {
if (!isFormValid.value) return;
// Debug image upload state
console.log('📋 Asset Form Submit - Image Upload State:', {
selectedFiles: selectedFiles.value,
imageFileId: formData.value.imageFileId,
imageUrl: formData.value.imageUrl,
imageTitle: formData.value.imageTitle,
isUploading: isUploading.value,
imagePreview: !!imagePreview.value
});
// Prepare the data according to backend schema
const submitData = {
name: formData.value.name,
asset_identifier: formData.value.assetIdentifier,
description: formData.value.description,
category_id: formData.value.category,
location_id: formData.value.location,
vendor_id: formData.value.vendor || null,
status: formData.value.status,
acquisition_cost: formData.value.purchasePrice ? parseFloat(formData.value.purchasePrice) : 0,
acquisition_date: formData.value.purchaseDate || null,
manufacturer: formData.value.manufacturer,
model_number: formData.value.model,
serial_number: formData.value.serialNumber,
warranty_start_date: formData.value.purchaseDate || null,
warranty_expiration_date: formData.value.warrantyExpiry || null,
// Image fields
image_url: formData.value.imageFileId || null,
image_title: formData.value.imageTitle || null,
image_alt_text: formData.value.imageAltText || null,
};
console.log('🚨 AssetForm submitting asset data:', {
mode: props.mode,
submitData,
fieldCount: Object.keys(submitData).length
});
emit('submit', submitData);
};
const handleCancel = () => {
emit('cancel');
};
// Load form data from API
const loadFormData = async () => {
try {
// Use the enhanced authentication service
const { authService } = await import('../../services/auth');
const { directusApi } = await import('../../services/directus');
// Ensure authentication
await authService.ensureAuthenticated();
// Load categories
const categoriesResponse = await directusApi.get('/items/asset_categories', {
params: { fields: ['id', 'name', 'color'] }
});
categories.value = categoriesResponse.data.data.map(cat => ({
title: cat.name,
value: cat.id
}));
// Load locations
const locationsResponse = await directusApi.get('/items/locations', {
params: { fields: ['id', 'name', 'building', 'floor'] }
});
locations.value = locationsResponse.data.data.map(loc => ({
title: `${loc.name}${loc.building ? ` - ${loc.building}` : ''}${loc.floor ? ` (${loc.floor})` : ''}`,
value: loc.id
}));
// Load vendors
const vendorsResponse = await directusApi.get('/items/vendors', {
params: { fields: ['id', 'name', 'vendor_type'] }
});
vendors.value = vendorsResponse.data.data.map(vendor => ({
title: vendor.name,
value: vendor.id
}));
console.log('📋 Form data loaded:', {
categories: categories.value.length,
locations: locations.value.length,
vendors: vendors.value.length
});
} catch (error) {
console.error('Failed to load form data:', error);
throw error;
}
};
// Initialize form data based on props
const initializeForm = () => {
if (props.mode === 'edit' && props.initialData) {
// Map backend data to form structure
formData.value = {
name: props.initialData.name || '',
assetIdentifier: props.initialData.asset_identifier || '',
description: props.initialData.description || '',
category: props.initialData.category_id?.id || '',
status: props.initialData.status || 'active',
location: props.initialData.location_id?.id || '',
assignedTo: props.initialData.assigned_to || null,
purchasePrice: props.initialData.acquisition_cost || null,
currentValue: props.initialData.current_value || null,
purchaseDate: props.initialData.acquisition_date || '',
warrantyExpiry: props.initialData.warranty_expiration_date || '',
manufacturer: props.initialData.manufacturer || '',
model: props.initialData.model_number || '',
serialNumber: props.initialData.serial_number || '',
vendor: props.initialData.vendor_id?.id || '',
imageFileId: props.initialData.image_url?.id || props.initialData.image_url || null,
imageUrl: props.initialData.image_url ? fileUploadService.getImageUrl(props.initialData.image_url.id || props.initialData.image_url) : null,
imageTitle: props.initialData.image_title || '',
imageAltText: props.initialData.image_alt_text || '',
};
// Set image preview for edit mode
if (formData.value.imageUrl) {
imagePreview.value = formData.value.imageUrl;
}
}
};
// Watch for prop changes
watch(() => props.initialData, initializeForm, { deep: true });
onMounted(async () => {
await loadFormData();
initializeForm();
});
// Watch for file selection and trigger upload
watch(selectedFiles, (newFiles) => {
console.log('📁 File selection changed:', {
newFiles: newFiles,
fileCount: newFiles ? newFiles.length : 0,
fileDetails: newFiles && newFiles.length > 0 ? {
name: newFiles[0].name,
size: newFiles[0].size,
type: newFiles[0].type,
isFile: newFiles[0] instanceof File
} : null
});
console.log('🔍 Watcher condition check:', {
hasNewFiles: !!newFiles,
hasLength: newFiles ? newFiles.length > 0 : false,
willCallUpload: !!(newFiles && newFiles.length > 0)
});
// Handle different possible formats from v-file-input
let filesToUpload = null;
if (newFiles) {
if (Array.isArray(newFiles) && newFiles.length > 0) {
// Standard array format
filesToUpload = newFiles;
console.log('📁 Files detected as array');
} else if (newFiles instanceof File) {
// Single file object
filesToUpload = [newFiles];
console.log('📁 Single file detected, converting to array');
} else if (newFiles instanceof FileList && newFiles.length > 0) {
// FileList object
filesToUpload = Array.from(newFiles);
console.log('📁 FileList detected, converting to array');
}
}
if (filesToUpload && filesToUpload.length > 0) {
console.log('✅ Calling handleFileUpload with:', filesToUpload);
handleFileUpload(filesToUpload);
} else {
console.log('❌ Not calling handleFileUpload - no valid files found');
}
});
return {
form,
isFormValid,
formData,
selectedFiles,
imagePreview,
isUploading,
uploadProgress,
categories,
locations,
vendors,
statusOptions,
users,
nameRules,
identifierRules,
categoryRules,
statusRules,
locationRules,
handleFileUpload,
removeImage,
handleSubmit,
handleCancel,
};
},
};
</script>
<style scoped>
.form-section {
border-bottom: 1px solid #F3F2F1;
padding-bottom: 24px;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.enterprise-field {
:deep(.v-field) {
border: 1px solid #E1DFDD;
border-radius: 4px;
transition: all 0.2s cubic-bezier(0.1, 0.9, 0.2, 1);
background-color: #FFFFFF;
&:hover {
border-color: #C8C6C4;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
&.v-field--focused {
border-color: #0078D4;
box-shadow: 0 0 0 1px #0078D4, 0 2px 4px rgba(0, 120, 212, 0.2);
transform: translateY(-1px);
}
&.v-field--error {
border-color: #D13438;
box-shadow: 0 0 0 1px #D13438;
}
}
:deep(.v-field__input) {
padding: 12px 16px;
font-size: 14px;
line-height: 20px;
color: #323130;
&::placeholder {
color: #A19F9D;
}
}
:deep(.v-field__outline) {
display: none;
}
:deep(.v-label) {
font-size: 14px;
font-weight: 600;
color: #323130;
margin-bottom: 8px;
}
}
.image-upload-container {
position: relative;
}
.image-preview {
position: relative;
display: inline-block;
.remove-image-btn {
position: absolute;
top: -8px;
right: -8px;
background: #FFFFFF;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
}
}
.form-actions {
.v-btn {
min-width: 120px;
}
}
</style>

View File

@ -0,0 +1,87 @@
<!-- frontend/src/components/common/AppFooter.vue -->
<template>
<v-footer
app
color="grey-lighten-4"
height="48"
class="d-flex align-center justify-space-between px-4"
>
<!-- Left side - Copyright -->
<div class="text-caption text-grey-darken-1">
© {{ currentYear }} Enterprise Asset Management. All rights reserved.
</div>
<!-- Right side - Version and links -->
<div class="d-flex align-center text-caption text-grey-darken-1">
<span class="mr-4">v{{ version }}</span>
<v-btn
variant="text"
size="small"
@click="showSupport"
class="text-caption"
>
Support
</v-btn>
<v-btn
variant="text"
size="small"
@click="showPrivacy"
class="text-caption"
>
Privacy
</v-btn>
<v-btn
variant="text"
size="small"
@click="showTerms"
class="text-caption"
>
Terms
</v-btn>
</div>
</v-footer>
</template>
<script>
import { computed } from 'vue';
import { useUIStore } from '../../stores/ui';
export default {
name: 'AppFooter',
setup() {
const uiStore = useUIStore();
const currentYear = computed(() => new Date().getFullYear());
const version = computed(() => '1.0.0'); // Could come from package.json or env
const showSupport = () => {
uiStore.showInfo('Support: Contact us at support@assetmanagement.com');
};
const showPrivacy = () => {
uiStore.showInfo('Privacy Policy: Available at /privacy');
};
const showTerms = () => {
uiStore.showInfo('Terms of Service: Available at /terms');
};
return {
currentYear,
version,
showSupport,
showPrivacy,
showTerms,
};
},
};
</script>
<style scoped>
.v-footer {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
</style>

View File

@ -0,0 +1,244 @@
<!-- frontend/src/components/common/AppHeader.vue -->
<template>
<v-app-bar app color="white" elevation="0" height="48" class="enterprise-header">
<!-- Menu toggle button -->
<v-app-bar-nav-icon
@click="$emit('toggle-drawer')"
color="neutral-primary"
class="mr-2"
/>
<!-- App title -->
<v-toolbar-title class="text-title3 font-weight-bold enterprise-title">
Enterprise Asset Management
</v-toolbar-title>
<v-spacer />
<!-- Search bar -->
<v-text-field
v-model="searchQuery"
hide-details
placeholder="Search assets..."
prepend-inner-icon="mdi-magnify"
single-line
variant="outlined"
density="compact"
class="mx-4 enterprise-search"
style="max-width: 320px"
@keyup.enter="performSearch"
@click:clear="clearSearch"
clearable
bg-color="neutral-lightest"
/>
<!-- Notifications -->
<v-btn
icon
variant="text"
color="neutral-primary"
size="small"
@click="showNotifications"
class="mr-1"
>
<v-badge
color="error"
:content="notificationCount"
:model-value="notificationCount > 0"
>
<v-icon size="20">mdi-bell</v-icon>
</v-badge>
</v-btn>
<!-- Theme toggle -->
<v-btn
icon
variant="text"
color="neutral-primary"
size="small"
@click="toggleTheme"
class="mr-1"
>
<v-icon size="20">{{
isDarkTheme ? 'mdi-brightness-7' : 'mdi-brightness-4'
}}</v-icon>
</v-btn>
<!-- User menu -->
<v-menu offset-y>
<template #activator="{ props }">
<v-btn icon v-bind="props" class="ml-2">
<v-avatar size="36" color="secondary">
<v-icon>mdi-account</v-icon>
</v-avatar>
</v-btn>
</template>
<v-list>
<!-- User info -->
<v-list-item>
<v-list-item-title>{{
user?.first_name || 'User'
}}</v-list-item-title>
<v-list-item-subtitle>{{ user?.email }}</v-list-item-subtitle>
</v-list-item>
<v-divider />
<!-- Menu items -->
<v-list-item @click="goToProfile">
<template #prepend>
<v-icon>mdi-account-circle</v-icon>
</template>
<v-list-item-title>Profile</v-list-item-title>
</v-list-item>
<v-list-item @click="goToSettings">
<template #prepend>
<v-icon>mdi-cog</v-icon>
</template>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<v-divider />
<v-list-item @click="logout">
<template #prepend>
<v-icon>mdi-logout</v-icon>
</template>
<v-list-item-title>Logout</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
</template>
<script>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useUIStore } from '../../stores/ui';
export default {
name: 'AppHeader',
emits: ['toggle-drawer'],
setup() {
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUIStore();
const searchQuery = ref('');
const notificationCount = ref(3); // Mock notification count
const user = computed(() => authStore.user);
const isDarkTheme = computed(() => uiStore.isDarkTheme);
const performSearch = () => {
if (searchQuery.value.trim()) {
router.push({
name: 'Assets',
query: { search: searchQuery.value },
});
}
};
const clearSearch = () => {
searchQuery.value = '';
};
const showNotifications = () => {
uiStore.showInfo('Notifications feature coming soon!');
};
const toggleTheme = () => {
uiStore.toggleTheme();
};
const goToProfile = () => {
router.push('/profile');
};
const goToSettings = () => {
router.push('/settings');
};
const logout = async () => {
try {
await authStore.logout();
uiStore.showSuccess('Logged out successfully');
router.push('/login');
} catch (error) {
uiStore.showError('Failed to logout');
console.error('Logout failed:', error);
}
};
return {
searchQuery,
notificationCount,
user,
isDarkTheme,
performSearch,
clearSearch,
showNotifications,
toggleTheme,
goToProfile,
goToSettings,
logout,
};
},
};
</script>
<style scoped>
.enterprise-header {
border-bottom: 1px solid #E1DFDD;
background-color: #FFFFFF;
}
.enterprise-title {
color: #323130;
font-weight: 600;
font-size: 16px;
line-height: 22px;
}
.enterprise-search {
max-width: 320px;
:deep(.v-field) {
background-color: #F8F8F8;
border: 1px solid #E1DFDD;
border-radius: 4px;
font-size: 14px;
}
:deep(.v-field--focused) {
border-color: #0078D4;
box-shadow: 0 0 0 1px #0078D4;
}
:deep(.v-field__input) {
padding: 8px 12px;
min-height: 32px;
}
}
.v-btn {
transition: all 0.2s ease;
&:hover {
background-color: #F3F2F1;
}
}
@media (max-width: 768px) {
.enterprise-search {
max-width: 200px;
}
.enterprise-title {
font-size: 14px;
}
}
</style>

View File

@ -0,0 +1,382 @@
<!-- frontend/src/components/common/AppSidebar.vue -->
<template>
<v-navigation-drawer
v-model="drawer"
app
:temporary="!sidebarPersistent"
:permanent="sidebarPersistent"
:width="sidebarWidth"
:rail="sidebarCollapsed && sidebarPersistent"
class="enterprise-sidebar"
>
<!-- Navigation header -->
<div class="sidebar-header">
<v-list-item class="pa-4">
<template #prepend>
<v-avatar size="32" color="primary" class="mr-3">
<v-icon color="white">mdi-office-building</v-icon>
</v-avatar>
</template>
<v-list-item-title
class="text-title3 font-weight-bold"
v-if="!isCollapsed"
>
Asset Manager
</v-list-item-title>
<v-list-item-subtitle v-if="!isCollapsed">
{{ organization?.name || 'Enterprise' }}
</v-list-item-subtitle>
</v-list-item>
<!-- Sidebar controls -->
<div class="sidebar-controls pa-2" v-if="!isCollapsed">
<v-btn
icon
size="small"
variant="text"
@click="togglePersistent"
:color="sidebarPersistent ? 'primary' : 'default'"
>
<v-icon>{{
sidebarPersistent ? 'mdi-pin' : 'mdi-pin-outline'
}}</v-icon>
</v-btn>
<v-btn
icon
size="small"
variant="text"
@click="toggleCollapsed"
v-if="sidebarPersistent"
>
<v-icon>mdi-chevron-left</v-icon>
</v-btn>
</div>
</div>
<v-divider />
<!-- Main navigation -->
<v-list nav density="compact" class="sidebar-nav">
<v-list-item
v-for="item in mainMenuItems"
:key="item.title"
:to="item.route"
:prepend-icon="item.icon"
:title="!isCollapsed ? item.title : ''"
:active="$route.path === item.route"
class="nav-item"
>
<v-tooltip
v-if="isCollapsed"
activator="parent"
location="end"
:text="item.title"
/>
</v-list-item>
</v-list>
<v-divider class="my-2" v-if="!isCollapsed" />
<!-- Asset Management section -->
<v-list nav density="compact" class="sidebar-nav">
<v-list-subheader v-if="!isCollapsed" class="enterprise-subheader">
ASSET MANAGEMENT
</v-list-subheader>
<v-list-item
v-for="item in assetMenuItems"
:key="item.title"
:to="item.route"
:prepend-icon="item.icon"
:title="!isCollapsed ? item.title : ''"
:active="$route.path === item.route"
class="nav-item"
>
<v-tooltip
v-if="isCollapsed"
activator="parent"
location="end"
:text="item.title"
/>
</v-list-item>
</v-list>
<v-divider class="my-2" v-if="!isCollapsed" />
<!-- Administration section (if user has permissions) -->
<v-list nav density="compact" class="sidebar-nav" v-if="canAccessAdmin">
<v-list-subheader v-if="!isCollapsed" class="enterprise-subheader">
ADMINISTRATION
</v-list-subheader>
<v-list-item
v-for="item in adminMenuItems"
:key="item.title"
:to="item.route"
:prepend-icon="item.icon"
:title="!isCollapsed ? item.title : ''"
:active="$route.path === item.route"
class="nav-item"
>
<v-tooltip
v-if="isCollapsed"
activator="parent"
location="end"
:text="item.title"
/>
</v-list-item>
</v-list>
<!-- Subscription info at bottom -->
<template #append>
<v-divider />
<v-list-item class="pa-4">
<v-list-item-content>
<v-list-item-title class="text-caption text-uppercase">
{{ currentPlan || 'Free Plan' }}
</v-list-item-title>
<v-progress-linear
:model-value="usagePercentage"
:color="usageColor"
height="4"
class="mt-1"
/>
<v-list-item-subtitle class="text-caption mt-1">
{{ assetsUsed }} / {{ assetLimit }} assets
</v-list-item-subtitle>
</v-list-item-content>
<template #append>
<v-btn
icon="mdi-arrow-up-bold"
size="small"
variant="text"
@click="goToSubscription"
/>
</template>
</v-list-item>
</template>
</v-navigation-drawer>
</template>
<script>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useUIStore } from '../../stores/ui';
export default {
name: 'AppSidebar',
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUIStore();
const drawer = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const user = computed(() => authStore.user);
const organization = computed(() => ({ name: 'Demo Organization' })); // Mock data
const currentPlan = computed(() => 'Starter Plan'); // Mock data
const assetsUsed = computed(() => 15); // Mock data
const assetLimit = computed(() => 100); // Mock data
const usagePercentage = computed(
() => (assetsUsed.value / assetLimit.value) * 100
);
const usageColor = computed(() => {
const percentage = usagePercentage.value;
if (percentage >= 90) return 'error';
if (percentage >= 75) return 'warning';
return 'success';
});
const canAccessAdmin = computed(() => {
// Check if user has admin permissions
return user.value?.role === 'admin' || true; // Mock - allow access for demo
});
const sidebarPersistent = computed(() => uiStore.sidebarPersistent);
const sidebarCollapsed = computed(() => uiStore.sidebarCollapsed);
const sidebarWidth = computed(() => uiStore.sidebarWidth);
const isCollapsed = computed(
() => sidebarPersistent.value && sidebarCollapsed.value
);
const togglePersistent = () => {
uiStore.toggleSidebarPersistent();
};
const toggleCollapsed = () => {
uiStore.toggleSidebarCollapsed();
};
const mainMenuItems = [
{
title: 'Dashboard',
icon: 'mdi-view-dashboard',
route: '/',
},
{
title: 'Assets',
icon: 'mdi-package-variant',
route: '/assets',
},
];
const assetMenuItems = [
{
title: 'Work Orders',
icon: 'mdi-wrench',
route: '/work-orders',
},
{
title: 'Categories',
icon: 'mdi-tag-multiple',
route: '/categories',
},
{
title: 'Locations',
icon: 'mdi-map-marker',
route: '/locations',
},
{
title: 'Vendors',
icon: 'mdi-store',
route: '/vendors',
},
];
const adminMenuItems = [
{
title: 'Users',
icon: 'mdi-account-group',
route: '/users',
},
{
title: 'Reports',
icon: 'mdi-chart-bar',
route: '/reports',
},
{
title: 'Settings',
icon: 'mdi-cog',
route: '/settings',
},
];
const goToSubscription = () => {
router.push('/subscription');
};
return {
drawer,
user,
organization,
currentPlan,
assetsUsed,
assetLimit,
usagePercentage,
usageColor,
canAccessAdmin,
sidebarPersistent,
sidebarCollapsed,
sidebarWidth,
isCollapsed,
togglePersistent,
toggleCollapsed,
mainMenuItems,
assetMenuItems,
adminMenuItems,
goToSubscription,
};
},
};
</script>
<style scoped>
.enterprise-sidebar {
background-color: #f8f8f8;
border-right: 1px solid #e1dfdd;
}
.sidebar-header {
background-color: #ffffff;
border-bottom: 1px solid #e1dfdd;
}
.sidebar-controls {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #f3f2f1;
}
.sidebar-nav .nav-item {
border-radius: 4px;
margin: 0 8px 2px 8px;
transition: all 0.2s ease;
&:hover {
background-color: #f3f2f1;
}
&.v-list-item--active {
background-color: rgba(0, 120, 212, 0.1);
border-left: 3px solid #0078d4;
color: #0078d4;
font-weight: 600;
.v-icon {
color: #0078d4;
}
}
}
.enterprise-subheader {
font-size: 11px;
font-weight: 600;
color: #605e5c;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 16px 16px 8px 16px;
line-height: 16px;
}
.v-list-item-title {
font-size: 14px;
font-weight: 400;
color: #323130;
line-height: 20px;
}
.v-list-item-subtitle {
font-size: 12px;
color: #605e5c;
line-height: 16px;
}
/* Collapsed state styles */
.v-navigation-drawer--rail {
.nav-item {
justify-content: center;
.v-list-item__prepend {
margin-inline-end: 0;
}
}
}
/* Subscription info styling */
.v-list-item:last-child {
background-color: #ffffff;
border-top: 1px solid #e1dfdd;
margin: 0;
border-radius: 0;
}
</style>

View File

@ -0,0 +1,70 @@
<!-- frontend/src/components/common/EmptyState.vue -->
<template>
<div class="empty-state text-center py-12">
<!-- Icon -->
<v-icon :icon="icon" size="96" color="grey-lighten-1" class="mb-4" />
<!-- Title -->
<h3 class="text-h5 font-weight-medium mb-2 text-grey-darken-2">
{{ title }}
</h3>
<!-- Subtitle -->
<p
class="text-body-1 text-grey-darken-1 mb-6 mx-auto"
style="max-width: 400px"
>
{{ subtitle }}
</p>
<!-- Action button -->
<v-btn
v-if="action"
color="primary"
size="large"
:prepend-icon="actionIcon"
@click="$emit('action')"
>
{{ action }}
</v-btn>
</div>
</template>
<script>
export default {
name: 'EmptyState',
props: {
icon: {
type: String,
default: 'mdi-package-variant',
},
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
action: {
type: String,
default: null,
},
actionIcon: {
type: String,
default: 'mdi-plus',
},
},
emits: ['action'],
};
</script>
<style scoped>
.empty-state {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>

267
frontend/src/main.js Normal file
View File

@ -0,0 +1,267 @@
// frontend/src/main.js
import { createApp } from 'vue';
import { createVuetify } from 'vuetify';
import { createPinia } from 'pinia';
import piniaPluginPersistedState from 'pinia-plugin-persistedstate';
import router from './router';
import App from './App.vue';
// Vuetify
import 'vuetify/styles';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
import '@mdi/font/css/materialdesignicons.css';
// Global styles
import './assets/styles/main.scss';
// Create Vuetify instance with shadcn/ui inspired theme
const vuetify = createVuetify({
theme: {
defaultTheme: 'light',
themes: {
light: {
dark: false,
colors: {
// shadcn/ui inspired color palette
primary: '#0F172A', // slate-900 - primary text/buttons
secondary: '#64748B', // slate-500 - secondary text
accent: '#3B82F6', // blue-500 - accent/links
error: '#EF4444', // red-500 - destructive
warning: '#F59E0B', // amber-500 - warning
info: '#06B6D4', // cyan-500 - info
success: '#10B981', // emerald-500 - success
surface: '#FFFFFF', // white - card backgrounds
background: '#FFFFFF', // white - page background
// shadcn/ui color system
'background': '#FFFFFF',
'foreground': '#0F172A',
'card': '#FFFFFF',
'card-foreground': '#0F172A',
'popover': '#FFFFFF',
'popover-foreground': '#0F172A',
'primary': '#0F172A',
'primary-foreground': '#F8FAFC',
'secondary': '#F1F5F9',
'secondary-foreground': '#0F172A',
'muted': '#F1F5F9',
'muted-foreground': '#64748B',
'accent': '#F1F5F9',
'accent-foreground': '#0F172A',
'destructive': '#EF4444',
'destructive-foreground': '#F8FAFC',
'border': '#E2E8F0',
'input': '#F1F5F9',
'ring': '#3B82F6',
// Status colors (shadcn/ui style)
'status-active': '#10B981',
'status-inactive': '#64748B',
'status-maintenance': '#F59E0B',
'status-retired': '#94A3B8',
'status-disposed': '#EF4444',
// Semantic colors
'destructive-50': '#FEF2F2',
'destructive-500': '#EF4444',
'destructive-900': '#7F1D1D',
},
},
dark: {
dark: true,
colors: {
// shadcn/ui dark theme
primary: '#F8FAFC',
secondary: '#64748B',
accent: '#3B82F6',
error: '#F87171',
warning: '#FBBF24',
info: '#22D3EE',
success: '#34D399',
surface: '#0F172A',
background: '#020617',
// shadcn/ui dark color system
'background': '#020617',
'foreground': '#F8FAFC',
'card': '#0F172A',
'card-foreground': '#F8FAFC',
'popover': '#0F172A',
'popover-foreground': '#F8FAFC',
'primary': '#F8FAFC',
'primary-foreground': '#0F172A',
'secondary': '#1E293B',
'secondary-foreground': '#F8FAFC',
'muted': '#1E293B',
'muted-foreground': '#94A3B8',
'accent': '#1E293B',
'accent-foreground': '#F8FAFC',
'destructive': '#7F1D1D',
'destructive-foreground': '#F8FAFC',
'border': '#1E293B',
'input': '#1E293B',
'ring': '#3B82F6',
// Status colors (dark theme)
'status-active': '#34D399',
'status-inactive': '#64748B',
'status-maintenance': '#FBBF24',
'status-retired': '#94A3B8',
'status-disposed': '#F87171',
},
},
},
},
icons: {
defaultSet: 'mdi',
aliases,
sets: {
mdi,
},
},
defaults: {
VCard: {
elevation: 0,
rounded: 'lg',
style: 'border: 1px solid rgb(226, 232, 240);',
},
VBtn: {
rounded: 'md',
style: 'text-transform: none; font-weight: 500; letter-spacing: 0;',
elevation: 0,
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
hideDetails: 'auto',
rounded: 'md',
style: 'border-radius: 6px;',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
hideDetails: 'auto',
rounded: 'md',
style: 'border-radius: 6px;',
},
VTextarea: {
variant: 'outlined',
density: 'comfortable',
hideDetails: 'auto',
rounded: 'md',
},
VDataTable: {
density: 'comfortable',
itemsPerPage: 25,
elevation: 0,
style: 'border: 1px solid rgb(226, 232, 240); border-radius: 8px;',
},
VChip: {
size: 'small',
rounded: 'md',
elevation: 0,
},
VAppBar: {
elevation: 0,
style: 'border-bottom: 1px solid rgb(226, 232, 240);',
},
VNavigationDrawer: {
elevation: 0,
style: 'border-right: 1px solid rgb(226, 232, 240);',
},
VAlert: {
elevation: 0,
rounded: 'md',
style: 'border: 1px solid;',
},
VDialog: {
rounded: 'lg',
elevation: 0,
},
VMenu: {
elevation: 0,
rounded: 'md',
style: 'border: 1px solid rgb(226, 232, 240);',
},
},
});
// Create Pinia store with persistence
const pinia = createPinia();
pinia.use(piniaPluginPersistedState);
// Create Vue app
const app = createApp(App);
// Global mixins for common functionality
app.mixin({
methods: {
// Format currency
formatCurrency(amount, currency = 'USD') {
if (amount == null) return '-';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
},
// Format date
formatDate(date, options = {}) {
if (!date) return '-';
const defaultOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
};
return new Intl.DateTimeFormat('en-US', {
...defaultOptions,
...options,
}).format(new Date(date));
},
// Get status color
getStatusColor(status) {
const statusColors = {
active: 'status-active',
inactive: 'status-inactive',
maintenance: 'status-maintenance',
retired: 'status-retired',
disposed: 'status-disposed',
// Work order statuses
draft: 'grey',
submitted: 'info',
approved: 'success',
assigned: 'warning',
in_progress: 'warning',
completed: 'success',
cancelled: 'error',
// Priority colors
low: 'priority-low',
medium: 'priority-medium',
high: 'priority-high',
urgent: 'priority-urgent',
emergency: 'priority-emergency',
};
return statusColors[status] || 'grey';
},
// Truncate text
truncateText(text, length = 50) {
if (!text) return '';
return text.length > length ? text.substring(0, length) + '...' : text;
},
},
});
// Use plugins
app.use(vuetify);
app.use(pinia);
app.use(router);
// Mount the app
app.mount('#app');
export default app;

View File

@ -0,0 +1,89 @@
// frontend/src/repositories/AuthRepository.js
import { directusApi } from '../services/directus';
export class AuthRepository {
constructor() {
this.api = directusApi;
}
async login(email, password) {
try {
const response = await this.api.post('/auth/login', {
email,
password,
});
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async logout() {
try {
const response = await this.api.post('/auth/logout');
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async refresh() {
try {
const refreshToken = localStorage.getItem('directus_refresh_token');
const response = await this.api.post('/auth/refresh', {
refresh_token: refreshToken,
});
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async getMe() {
try {
const response = await this.api.get('/users/me');
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async updatePassword(currentPassword, newPassword) {
try {
const response = await this.api.patch('/users/me', {
password: newPassword,
current_password: currentPassword,
});
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
async requestPasswordReset(email) {
try {
const response = await this.api.post('/auth/password/request', {
email,
});
return response.data;
} catch (error) {
throw this.handleError(error);
}
}
handleError(error) {
console.error('Auth Repository Error:', error);
if (error.response?.data?.errors) {
const message =
error.response.data.errors[0]?.message || 'Authentication failed';
return new Error(message);
}
if (error.response?.status === 401) {
return new Error('Invalid email or password');
}
return new Error(error.message || 'Authentication error occurred');
}
}

View File

@ -0,0 +1,131 @@
// frontend/src/repositories/NodeAssetRepository.js
import { nodeApi } from '../services/nodeApi';
export class NodeAssetRepository {
constructor() {
this.api = nodeApi;
}
async getAll(params = {}) {
try {
console.log('🔍 NodeAssetRepository.getAll called');
const response = await this.api.get('/api/assets', { params });
console.log('✅ Assets retrieved:', response.data);
return response.data;
} catch (error) {
console.error('❌ Failed to get assets:', error);
throw this.handleError(error);
}
}
async getById(id, params = {}) {
try {
console.log(`🔍 NodeAssetRepository.getById called for ID: ${id}`);
const response = await this.api.get(`/api/assets/${id}`, { params });
console.log('✅ Asset retrieved:', response.data);
return response.data;
} catch (error) {
console.error(`❌ Failed to get asset ${id}:`, error);
throw this.handleError(error);
}
}
async create(data) {
try {
console.log('📝 NodeAssetRepository.create called with data:', data);
const response = await this.api.post('/api/assets', data);
console.log('✅ Asset created:', response.data);
return response.data;
} catch (error) {
console.error('❌ Failed to create asset:', error);
throw this.handleError(error);
}
}
async update(id, data) {
try {
console.log(`📝 NodeAssetRepository.update called for ID: ${id}`, data);
const response = await this.api.patch(`/api/assets/${id}`, data);
console.log('✅ Asset updated:', response.data);
return response.data;
} catch (error) {
console.error(`❌ Failed to update asset ${id}:`, error);
throw this.handleError(error);
}
}
async delete(id) {
try {
console.log(`🗑️ NodeAssetRepository.delete called for ID: ${id}`);
const response = await this.api.delete(`/api/assets/${id}`);
console.log('✅ Asset deleted');
return response.data;
} catch (error) {
console.error(`❌ Failed to delete asset ${id}:`, error);
throw this.handleError(error);
}
}
async generateAssetIdentifier(categoryId = null) {
try {
// For now, generate a simple identifier
// In production, this would call a backend endpoint
const prefix = categoryId ? 'AST' : 'GEN';
const timestamp = Date.now().toString().slice(-6);
const random = Math.floor(Math.random() * 100)
.toString()
.padStart(2, '0');
return `${prefix}${timestamp}${random}`;
} catch (error) {
throw this.handleError(error);
}
}
generateQRCodeData(assetData) {
// Generate QR code data with asset information
const qrData = {
type: 'asset',
id: assetData.id || 'pending',
identifier: assetData.asset_identifier,
name: assetData.name,
timestamp: new Date().toISOString(),
url: typeof window !== 'undefined'
? `${window.location.origin}/assets/${assetData.id || 'pending'}`
: `/assets/${assetData.id || 'pending'}`,
};
return JSON.stringify(qrData);
}
handleError(error) {
console.error('Node Asset Repository Error:', error);
console.error('Error response:', error.response?.data);
console.error('Error status:', error.response?.status);
if (error.response?.data?.error) {
return new Error(error.response.data.error);
}
if (error.response?.status === 400) {
return new Error(`Bad request: ${error.response.data?.error || 'Invalid data provided'}`);
}
if (error.response?.status === 401) {
return new Error('Unauthorized access');
}
if (error.response?.status === 403) {
return new Error('Access forbidden');
}
if (error.response?.status === 404) {
return new Error('Asset not found');
}
if (error.response?.status >= 500) {
return new Error('Server error occurred');
}
return new Error(error.message || 'An unexpected error occurred');
}
}

View File

@ -0,0 +1,111 @@
// frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const routes = [
{
path: '/',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { requiresAuth: true, title: 'Dashboard' },
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: { requiresAuth: false, title: 'Login' },
},
{
path: '/assets',
name: 'Assets',
component: () => import('../views/Assets.vue'),
meta: { requiresAuth: true, title: 'Assets' },
},
{
path: '/assets/add',
name: 'AddAsset',
component: () => import('../views/AddAsset.vue'),
meta: { requiresAuth: true, title: 'Add Asset' },
},
{
path: '/assets/:id',
name: 'AssetDetail',
component: () => import('../views/AssetDetail.vue'),
meta: { requiresAuth: true, title: 'Asset Details' },
props: true,
},
{
path: '/assets/:id/edit',
name: 'EditAsset',
component: () => import('../views/EditAsset.vue'),
meta: { requiresAuth: true, title: 'Edit Asset' },
props: true,
},
// {
// path: '/work-orders',
// name: 'WorkOrders',
// component: () => import('../views/WorkOrders.vue'),
// meta: { requiresAuth: true, title: 'Work Orders' },
// },
// {
// path: '/subscription',
// name: 'Subscription',
// component: () => import('../views/Subscription.vue'),
// meta: { requiresAuth: true, title: 'Subscription' },
// },
// {
// path: '/settings',
// name: 'Settings',
// component: () => import('../views/Settings.vue'),
// meta: { requiresAuth: true, title: 'Settings' },
// },
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
redirect: '/',
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// Navigation guards
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// Set page title
document.title = to.meta.title
? `${to.meta.title} - Enterprise Asset Management`
: 'Enterprise Asset Management';
// Check if route requires authentication
if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) {
next('/login');
return;
}
// Fetch user data if not already loaded
if (!authStore.user) {
try {
await authStore.fetchUser();
} catch (error) {
console.error('Failed to fetch user:', error);
next('/login');
return;
}
}
}
// Redirect authenticated users away from login
if (to.name === 'Login' && authStore.isAuthenticated) {
next('/');
return;
}
next();
});
export default router;

View File

@ -0,0 +1,247 @@
// frontend/src/services/auth.js
import { directusApi } from './directus';
/**
* Environment-specific authentication service
* Handles different authentication flows for development vs production
*/
class AuthService {
constructor() {
this.isDevelopment = import.meta.env.DEV;
this.isProduction = import.meta.env.PROD;
// Development credentials (only used in dev mode)
this.devCredentials = {
email: 'admin@assetmanagement.com',
password: 'AssetAdmin2024!'
};
}
/**
* Get current authentication status
*/
isAuthenticated() {
const token = localStorage.getItem('directus_token');
const refreshToken = localStorage.getItem('directus_refresh_token');
return !!(token || refreshToken);
}
/**
* Get current access token
*/
getAccessToken() {
return localStorage.getItem('directus_token');
}
/**
* Get refresh token
*/
getRefreshToken() {
return localStorage.getItem('directus_refresh_token');
}
/**
* Store authentication tokens
*/
storeTokens(accessToken, refreshToken) {
localStorage.setItem('directus_token', accessToken);
if (refreshToken) {
localStorage.setItem('directus_refresh_token', refreshToken);
}
console.log('✅ Authentication tokens stored');
}
/**
* Clear authentication tokens
*/
clearTokens() {
localStorage.removeItem('directus_token');
localStorage.removeItem('directus_refresh_token');
console.log('🗑️ Authentication tokens cleared');
}
/**
* Login with email and password
*/
async login(email, password) {
try {
console.log('🔑 Attempting login...');
const response = await directusApi.post('/auth/login', {
email,
password
});
const { access_token, refresh_token } = response.data.data;
this.storeTokens(access_token, refresh_token);
console.log('✅ Login successful');
return response.data;
} catch (error) {
console.error('❌ Login failed:', error);
throw new Error(this.getErrorMessage(error));
}
}
/**
* Development auto-login
* Only works in development mode
*/
async devAutoLogin() {
if (!this.isDevelopment) {
throw new Error('Auto-login only available in development mode');
}
console.log('🔧 Development auto-login...');
return await this.login(
this.devCredentials.email,
this.devCredentials.password
);
}
/**
* Ensure authentication (auto-login in dev, redirect in prod)
*/
async ensureAuthenticated() {
if (this.isAuthenticated()) {
return true;
}
if (this.isDevelopment) {
try {
await this.devAutoLogin();
return true;
} catch (error) {
console.error('❌ Development auto-login failed:', error);
return false;
}
} else {
// In production, redirect to login
console.log('🔒 Not authenticated, redirecting to login');
window.location.href = '/login';
return false;
}
}
/**
* Refresh access token
*/
async refreshToken() {
try {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
console.log('🔄 Refreshing access token...');
const response = await directusApi.post('/auth/refresh', {
refresh_token: refreshToken
});
const { access_token, refresh_token: newRefreshToken } = response.data.data;
this.storeTokens(access_token, newRefreshToken);
console.log('✅ Token refresh successful');
return response.data;
} catch (error) {
console.error('❌ Token refresh failed:', error);
this.clearTokens();
throw error;
}
}
/**
* Logout user
*/
async logout() {
try {
// Try to logout on server
await directusApi.post('/auth/logout', {
refresh_token: this.getRefreshToken()
});
} catch (error) {
console.warn('⚠️ Server logout failed (continuing with local logout):', error);
} finally {
this.clearTokens();
console.log('👋 Logout completed');
}
}
/**
* Check if user has specific role
*/
async getCurrentUser() {
try {
const response = await directusApi.get('/users/me', {
params: {
fields: ['*', 'role.*', 'organization_id.*']
}
});
return response.data.data;
} catch (error) {
console.error('❌ Failed to get current user:', error);
throw error;
}
}
/**
* Check if user has specific permissions
*/
async hasPermission(collection, action) {
try {
const user = await this.getCurrentUser();
if (!user.role) return false;
// Admin role has all permissions
if (user.role.name === 'Administrator') {
return true;
}
// Check specific permissions
const response = await directusApi.get('/permissions', {
params: {
filter: {
role: { _eq: user.role.id },
collection: { _eq: collection },
action: { _eq: action }
}
}
});
return response.data.data.length > 0;
} catch (error) {
console.error('❌ Permission check failed:', error);
return false;
}
}
/**
* Get user-friendly error message
*/
getErrorMessage(error) {
if (error.response?.data?.errors?.[0]?.message) {
return error.response.data.errors[0].message;
}
if (error.response?.data?.message) {
return error.response.data.message;
}
return error.message || 'An unknown error occurred';
}
/**
* Get environment info
*/
getEnvironmentInfo() {
return {
mode: this.isDevelopment ? 'development' : 'production',
autoLogin: this.isDevelopment,
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:8055'
};
}
}
// Export singleton instance
export const authService = new AuthService();
export default authService;

View File

@ -0,0 +1,276 @@
// frontend/src/services/cache.js
/**
* Cache Management Service
* Handles cache invalidation and refresh for better data consistency
*/
class CacheService {
constructor() {
this.localCache = new Map();
this.cacheExpiry = new Map();
this.defaultTTL = 5 * 60 * 1000; // 5 minutes
}
/**
* Set item in local cache with TTL
*/
set(key, value, ttl = this.defaultTTL) {
this.localCache.set(key, value);
this.cacheExpiry.set(key, Date.now() + ttl);
console.log(`📦 Cached: ${key} (TTL: ${ttl}ms)`);
}
/**
* Get item from local cache
*/
get(key) {
const expiry = this.cacheExpiry.get(key);
if (!expiry || Date.now() > expiry) {
this.delete(key);
return null;
}
return this.localCache.get(key);
}
/**
* Delete item from local cache
*/
delete(key) {
this.localCache.delete(key);
this.cacheExpiry.delete(key);
console.log(`🗑️ Cache deleted: ${key}`);
}
/**
* Check if item exists and is not expired
*/
has(key) {
return this.get(key) !== null;
}
/**
* Clear all local cache
*/
clear() {
this.localCache.clear();
this.cacheExpiry.clear();
console.log('🗑️ All local cache cleared');
}
/**
* Clear expired cache entries
*/
clearExpired() {
const now = Date.now();
let cleared = 0;
for (const [key, expiry] of this.cacheExpiry.entries()) {
if (now > expiry) {
this.delete(key);
cleared++;
}
}
if (cleared > 0) {
console.log(`🗑️ Cleared ${cleared} expired cache entries`);
}
}
/**
* Clear server cache (placeholder for future implementation)
*/
async clearServerCache() {
console.log('🔄 Server cache clearing not implemented for node_api');
// Future: Implement node_api cache clearing if needed
return { success: true };
}
/**
* Invalidate cache for specific collection
*/
invalidateCollection(collection) {
const keysToDelete = [];
for (const key of this.localCache.keys()) {
if (key.includes(collection)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.delete(key));
if (keysToDelete.length > 0) {
console.log(`🗑️ Invalidated ${keysToDelete.length} cache entries for collection: ${collection}`);
}
}
/**
* Refresh cache for assets
*/
async refreshAssets() {
this.invalidateCollection('assets');
console.log('🔄 Assets cache invalidated, will reload on next request');
}
/**
* Refresh cache for categories
*/
async refreshCategories() {
this.invalidateCollection('asset_categories');
console.log('🔄 Categories cache invalidated, will reload on next request');
}
/**
* Refresh cache for locations
*/
async refreshLocations() {
this.invalidateCollection('locations');
console.log('🔄 Locations cache invalidated, will reload on next request');
}
/**
* Refresh all cached data
*/
async refreshAll() {
this.clear();
// Clear server cache (currently no-op for node_api)
await this.clearServerCache();
console.log('🔄 All cache refreshed');
}
/**
* Cache management for API responses
*/
async cacheApiResponse(cacheKey, apiCall, ttl = this.defaultTTL) {
// Check if we have cached data
const cached = this.get(cacheKey);
if (cached) {
console.log(`📦 Using cached data for: ${cacheKey}`);
return cached;
}
// Fetch fresh data
console.log(`🌐 Fetching fresh data for: ${cacheKey}`);
const response = await apiCall();
// Cache the response
this.set(cacheKey, response, ttl);
return response;
}
/**
* Smart cache invalidation based on user actions
*/
onAssetCreated() {
this.invalidateCollection('assets');
console.log('🆕 Asset created - cache invalidated');
}
onAssetUpdated() {
this.invalidateCollection('assets');
console.log('✏️ Asset updated - cache invalidated');
}
onAssetDeleted() {
this.invalidateCollection('assets');
console.log('🗑️ Asset deleted - cache invalidated');
}
onCategoryChanged() {
this.invalidateCollection('asset_categories');
this.invalidateCollection('assets'); // Assets reference categories
console.log('🏷️ Category changed - related cache invalidated');
}
onLocationChanged() {
this.invalidateCollection('locations');
this.invalidateCollection('assets'); // Assets reference locations
console.log('📍 Location changed - related cache invalidated');
}
/**
* Permission cache invalidation
*/
onPermissionsChanged() {
this.invalidateCollection('permissions');
this.invalidateCollection('roles');
console.log('🔐 Permissions changed - security cache invalidated');
}
/**
* Start periodic cleanup of expired cache
*/
startCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
}
this.cleanupTimer = setInterval(() => {
this.clearExpired();
}, 60000); // Clean up every minute
console.log('⏰ Cache cleanup timer started');
}
/**
* Stop periodic cleanup
*/
stopCleanupTimer() {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
console.log('⏰ Cache cleanup timer stopped');
}
}
/**
* Get cache statistics
*/
getStats() {
const now = Date.now();
let active = 0;
let expired = 0;
for (const [key, expiry] of this.cacheExpiry.entries()) {
if (now > expiry) {
expired++;
} else {
active++;
}
}
return {
total: this.localCache.size,
active,
expired,
memoryUsage: this.estimateMemoryUsage()
};
}
/**
* Estimate memory usage (rough calculation)
*/
estimateMemoryUsage() {
let totalSize = 0;
for (const [key, value] of this.localCache.entries()) {
totalSize += key.length * 2; // Rough string size
totalSize += JSON.stringify(value).length * 2; // Rough object size
}
return `${Math.round(totalSize / 1024)}KB`;
}
}
// Export singleton instance
export const cacheService = new CacheService();
// Start cleanup timer on module load
cacheService.startCleanupTimer();
export default cacheService;

View File

@ -0,0 +1,103 @@
// frontend/src/services/directus.js
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8055';
// Create axios instance
export const directusApi = axios.create({
baseURL: API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
directusApi.interceptors.request.use(
(config) => {
const token = localStorage.getItem('directus_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle token refresh
directusApi.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('directus_refresh_token');
if (refreshToken) {
console.log('🔄 Token expired, attempting refresh...');
const response = await axios.post(`${API_URL}/auth/refresh`, {
refresh_token: refreshToken,
});
const { access_token, refresh_token: newRefreshToken } = response.data.data;
localStorage.setItem('directus_token', access_token);
localStorage.setItem('directus_refresh_token', newRefreshToken);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
console.log('✅ Token refreshed successfully');
return directusApi(originalRequest);
} else {
console.log('❌ No refresh token available');
throw new Error('No refresh token available');
}
} catch (refreshError) {
console.error('❌ Token refresh failed:', refreshError);
// Clear tokens and redirect to login
localStorage.removeItem('directus_token');
localStorage.removeItem('directus_refresh_token');
// For development, try to re-authenticate automatically
if (import.meta.env.DEV) {
console.log('🔧 Development mode: attempting automatic re-authentication');
return await handleDevAuthentication(originalRequest);
}
// In production, redirect to login
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// Development auto-authentication helper
async function handleDevAuthentication(originalRequest) {
try {
const authResponse = await axios.post(`${API_URL}/auth/login`, {
email: 'admin@assetmanagement.com',
password: 'AssetAdmin2024!'
});
const { access_token, refresh_token } = authResponse.data.data;
localStorage.setItem('directus_token', access_token);
localStorage.setItem('directus_refresh_token', refresh_token);
originalRequest.headers.Authorization = `Bearer ${access_token}`;
console.log('✅ Development auto-authentication successful');
return directusApi(originalRequest);
} catch (authError) {
console.error('❌ Development auto-authentication failed:', authError);
throw authError;
}
}
export default directusApi;

View File

@ -0,0 +1,351 @@
// frontend/src/services/fileUpload.js
import { directusApi } from './directus';
/**
* File Upload Service for Directus Integration
* Handles image uploads, validation, and optimization
*/
class FileUploadService {
constructor() {
this.maxFileSize = 10 * 1024 * 1024; // 10MB
this.allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
this.maxDimensions = { width: 2048, height: 2048 };
}
/**
* Validate image file before upload
*/
validateImageFile(file) {
if (!file) {
throw new Error('No file provided');
}
// Check file size
if (file.size > this.maxFileSize) {
throw new Error(`File size too large. Maximum size is ${this.maxFileSize / 1024 / 1024}MB`);
}
// Check file type
if (!this.allowedTypes.includes(file.type)) {
throw new Error(`Invalid file type. Allowed types: ${this.allowedTypes.join(', ')}`);
}
// Check file name
if (!file.name || file.name.length < 1) {
throw new Error('Invalid file name');
}
return true;
}
/**
* Resize image if it exceeds maximum dimensions
*/
async optimizeImage(file) {
return new Promise((resolve) => {
// If file is small enough, return as-is
if (file.size < 500 * 1024) { // 500KB
resolve(file);
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = this.calculateOptimalDimensions(
img.width,
img.height,
this.maxDimensions.width,
this.maxDimensions.height
);
canvas.width = width;
canvas.height = height;
// Draw resized image
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob with compression
canvas.toBlob(
(blob) => {
// Create new file with optimized blob
const optimizedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now()
});
resolve(optimizedFile);
},
file.type,
0.85 // 85% quality
);
};
img.src = URL.createObjectURL(file);
});
}
/**
* Calculate optimal dimensions maintaining aspect ratio
*/
calculateOptimalDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
return { width: originalWidth, height: originalHeight };
}
const aspectRatio = originalWidth / originalHeight;
let width = maxWidth;
let height = width / aspectRatio;
if (height > maxHeight) {
height = maxHeight;
width = height * aspectRatio;
}
return {
width: Math.round(width),
height: Math.round(height)
};
}
/**
* Upload file to Directus Files API
*/
async uploadFile(file, folder = null, title = null) {
try {
console.log('📤 Starting file upload to Directus:', {
fileName: file.name,
fileSize: file.size,
fileType: file.type,
folder: folder,
title: title
});
// Check if we have authentication
const token = localStorage.getItem('directus_token');
console.log('🔐 Authentication token available:', !!token);
if (token) {
console.log('🔐 Token preview:', token.substring(0, 20) + '...');
} else {
console.error('❌ No authentication token found!');
}
const formData = new FormData();
formData.append('file', file);
if (folder) {
formData.append('folder', folder);
console.log('📁 Setting folder:', folder);
}
if (title) {
formData.append('title', title);
console.log('🏷️ Setting title:', title);
}
// Add metadata
formData.append('description', `Asset image uploaded at ${new Date().toISOString()}`);
console.log('📡 Making request to:', directusApi.defaults.baseURL + '/files');
const response = await directusApi.post('/files', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
// Track upload progress
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
console.log(`📊 Upload progress: ${percentCompleted}%`);
},
});
console.log('✅ File uploaded successfully:', response.data);
return response.data;
} catch (error) {
console.error('❌ File upload failed:', {
error: error,
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data
});
throw this.handleUploadError(error);
}
}
/**
* Upload asset image with validation and optimization
*/
async uploadAssetImage(file, assetName = '') {
try {
// Validate file
this.validateImageFile(file);
// Optimize image
const optimizedFile = await this.optimizeImage(file);
// Generate title
const title = assetName
? `${assetName} - Asset Image`
: 'Asset Image';
// Upload to Directus in 'asset-images' folder
return await this.uploadFile(optimizedFile, 'asset-images', title);
} catch (error) {
console.error('❌ Asset image upload failed:', error);
throw error;
}
}
/**
* Get image URL from file ID
*/
getImageUrl(fileId, params = {}) {
if (!fileId) return null;
const baseUrl = directusApi.defaults.baseURL || 'http://localhost:8055';
let url = `${baseUrl}/assets/${fileId}`;
// Add transformation parameters
const queryParams = new URLSearchParams();
if (params.width) queryParams.append('width', params.width);
if (params.height) queryParams.append('height', params.height);
if (params.quality) queryParams.append('quality', params.quality);
if (params.fit) queryParams.append('fit', params.fit);
if (params.format) queryParams.append('format', params.format);
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;
}
return url;
}
/**
* Get thumbnail URL
*/
getThumbnailUrl(fileId, size = 200) {
return this.getImageUrl(fileId, {
width: size,
height: size,
fit: 'cover',
quality: 80
});
}
/**
* Delete file from Directus
*/
async deleteFile(fileId) {
try {
console.log('🗑️ Deleting file from Directus:', fileId);
const response = await directusApi.delete(`/files/${fileId}`);
console.log('✅ File deleted successfully');
return response.data;
} catch (error) {
console.error('❌ File deletion failed:', error);
throw this.handleUploadError(error);
}
}
/**
* Handle upload errors with user-friendly messages
*/
handleUploadError(error) {
if (error.response) {
const status = error.response.status;
const message = error.response.data?.errors?.[0]?.message || error.response.data?.message;
switch (status) {
case 400:
return new Error(message || 'Invalid file format or corrupted file');
case 401:
return new Error('Authentication required. Please log in again.');
case 403:
return new Error('Permission denied. You do not have access to upload files.');
case 413:
return new Error('File too large. Please choose a smaller image.');
case 415:
return new Error('Unsupported file type. Please use JPEG, PNG, or WebP.');
case 422:
return new Error(message || 'File validation failed');
case 429:
return new Error('Too many upload requests. Please try again later.');
case 500:
return new Error('Server error occurred. Please try again.');
default:
return new Error(message || `Upload failed with status ${status}`);
}
}
if (error.code === 'NETWORK_ERROR') {
return new Error('Network error. Please check your connection.');
}
return new Error(error.message || 'An unexpected error occurred during upload');
}
/**
* Create asset images folder if it doesn't exist
*/
async ensureAssetImagesFolder() {
try {
// Check if folder exists
const foldersResponse = await directusApi.get('/folders', {
params: {
filter: { name: { _eq: 'asset-images' } }
}
});
if (foldersResponse.data.data.length > 0) {
return foldersResponse.data.data[0].id;
}
// Create folder if it doesn't exist
const createResponse = await directusApi.post('/folders', {
name: 'asset-images',
parent: null // Root level
});
console.log('📁 Created asset-images folder:', createResponse.data.data.id);
return createResponse.data.data.id;
} catch (error) {
console.warn('⚠️ Could not create asset-images folder:', error);
return null;
}
}
/**
* Get upload progress (for UI feedback)
*/
createProgressTracker() {
let progressCallback = null;
return {
onProgress: (callback) => {
progressCallback = callback;
},
getProgressHandler: () => (progressEvent) => {
if (progressCallback) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
progressCallback(percentCompleted);
}
}
};
}
}
// Export singleton instance
export const fileUploadService = new FileUploadService();
export default fileUploadService;

View File

@ -0,0 +1,48 @@
// frontend/src/services/nodeApi.js
import axios from 'axios';
import { useAuthStore } from '../stores/auth';
const NODE_API_URL = import.meta.env.VITE_NODE_API_URL || 'http://localhost:3001';
// Create axios instance for node API
export const nodeApi = axios.create({
baseURL: NODE_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
nodeApi.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for error handling
nodeApi.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.error('Node API Error:', error);
if (error.response?.status === 401) {
// Token expired or invalid, logout user
const authStore = useAuthStore();
authStore.logout();
}
return Promise.reject(error);
}
);
export default nodeApi;

View File

@ -0,0 +1,248 @@
// frontend/src/services/permissions.js
import { authService } from './auth';
/**
* Role-Based Access Control (RBAC) Service
* Manages user permissions and role-based access
*/
class PermissionsService {
constructor() {
this.permissions = new Map();
this.userRole = null;
this.userPermissions = [];
}
/**
* Initialize permissions for current user
*/
async initialize() {
try {
const user = await authService.getCurrentUser();
this.userRole = user.role;
if (this.userRole) {
console.log(`👤 User role: ${this.userRole.name}`);
await this.loadUserPermissions();
}
} catch (error) {
console.error('❌ Failed to initialize permissions:', error);
}
}
/**
* Load permissions for current user's role
*/
async loadUserPermissions() {
try {
const { directusApi } = await import('./directus');
// Use /permissions/me endpoint which returns permissions for current user
const response = await directusApi.get('/permissions/me');
console.log('📋 Raw permissions response:', response.data);
// Handle different response structures
let permissions = response.data.data || response.data || [];
// Ensure permissions is an array
if (!Array.isArray(permissions)) {
console.warn('⚠️ Permissions response is not an array:', typeof permissions);
permissions = [];
}
this.userPermissions = permissions;
// Cache permissions for quick lookup
this.userPermissions.forEach(perm => {
const key = `${perm.collection}:${perm.action}`;
this.permissions.set(key, perm);
});
console.log(`✅ Loaded ${this.userPermissions.length} permissions for current user`);
} catch (error) {
console.error('❌ Failed to load user permissions:', error);
// Initialize empty permissions array on error
this.userPermissions = [];
// For Administrator role, if permissions loading fails, assume full access
if (this.userRole?.name === 'Administrator') {
console.log('🔧 Administrator detected - skipping permission checks');
}
}
}
/**
* Check if user has permission for specific action on collection
*/
can(collection, action) {
// Admin role has all permissions
if (this.userRole?.name === 'Administrator') {
return true;
}
const key = `${collection}:${action}`;
return this.permissions.has(key);
}
/**
* Check multiple permissions at once
*/
canAny(checks) {
return checks.some(({ collection, action }) => this.can(collection, action));
}
/**
* Check if user can perform any CRUD operation on collection
*/
canAccess(collection) {
return this.canAny([
{ collection, action: 'read' },
{ collection, action: 'create' },
{ collection, action: 'update' },
{ collection, action: 'delete' }
]);
}
/**
* Get all collections user has access to
*/
getAccessibleCollections() {
const collections = new Set();
this.userPermissions.forEach(perm => {
collections.add(perm.collection);
});
return Array.from(collections);
}
/**
* Asset-specific permissions
*/
canCreateAssets() {
return this.can('assets', 'create');
}
canViewAssets() {
return this.can('assets', 'read');
}
canEditAssets() {
return this.can('assets', 'update');
}
canDeleteAssets() {
return this.can('assets', 'delete');
}
canManageAssets() {
return this.canAny([
{ collection: 'assets', action: 'create' },
{ collection: 'assets', action: 'update' },
{ collection: 'assets', action: 'delete' }
]);
}
/**
* Category management permissions
*/
canManageCategories() {
return this.canAny([
{ collection: 'asset_categories', action: 'create' },
{ collection: 'asset_categories', action: 'update' },
{ collection: 'asset_categories', action: 'delete' }
]);
}
/**
* Location management permissions
*/
canManageLocations() {
return this.canAny([
{ collection: 'locations', action: 'create' },
{ collection: 'locations', action: 'update' },
{ collection: 'locations', action: 'delete' }
]);
}
/**
* Organization management permissions
*/
canManageOrganization() {
return this.canAny([
{ collection: 'organizations', action: 'update' },
{ collection: 'subscription_plans', action: 'read' }
]);
}
/**
* Work order permissions
*/
canCreateWorkOrders() {
return this.can('work_orders', 'create');
}
canViewWorkOrders() {
return this.can('work_orders', 'read');
}
canManageWorkOrders() {
return this.canAny([
{ collection: 'work_orders', action: 'create' },
{ collection: 'work_orders', action: 'update' },
{ collection: 'work_orders', action: 'delete' }
]);
}
/**
* Get user role information
*/
getUserRole() {
return this.userRole;
}
/**
* Check if user is admin
*/
isAdmin() {
return this.userRole?.name === 'Administrator';
}
/**
* Get permission summary for debugging
*/
getPermissionSummary() {
return {
role: this.userRole?.name || 'Unknown',
isAdmin: this.isAdmin(),
totalPermissions: this.userPermissions.length,
collections: this.getAccessibleCollections(),
assets: {
canView: this.canViewAssets(),
canCreate: this.canCreateAssets(),
canEdit: this.canEditAssets(),
canDelete: this.canDeleteAssets()
},
workOrders: {
canView: this.canViewWorkOrders(),
canCreate: this.canCreateWorkOrders(),
canManage: this.canManageWorkOrders()
}
};
}
/**
* Clear cached permissions (call on logout)
*/
clear() {
this.permissions.clear();
this.userRole = null;
this.userPermissions = [];
console.log('🗑️ Permissions cache cleared');
}
}
// Export singleton instance
export const permissionsService = new PermissionsService();
export default permissionsService;

View File

@ -0,0 +1,541 @@
// frontend/src/stores/assets.js
import { defineStore } from 'pinia';
import { NodeAssetRepository } from '../repositories/NodeAssetRepository';
import { cacheService } from '../services/cache';
const assetRepository = new NodeAssetRepository();
export const useAssetsStore = defineStore('assets', {
state: () => ({
assets: [],
currentAsset: null,
categories: [],
locations: [],
vendors: [],
isLoading: false,
error: null,
pagination: {
page: 1,
limit: 25,
total: 0,
},
filters: {
search: '',
category: null,
location: null,
status: null,
},
}),
getters: {
filteredAssets: (state) => {
let filtered = state.assets;
if (state.filters.search) {
const search = state.filters.search.toLowerCase();
filtered = filtered.filter(
(asset) =>
asset.name.toLowerCase().includes(search) ||
asset.asset_identifier.toLowerCase().includes(search) ||
asset.serial_number?.toLowerCase().includes(search)
);
}
if (state.filters.category) {
filtered = filtered.filter(
(asset) => asset.category_id === state.filters.category
);
}
if (state.filters.location) {
filtered = filtered.filter(
(asset) => asset.location_id === state.filters.location
);
}
if (state.filters.status) {
filtered = filtered.filter(
(asset) => asset.status === state.filters.status
);
}
return filtered;
},
assetsByStatus: (state) => {
const grouped = {};
state.assets.forEach((asset) => {
if (!grouped[asset.status]) {
grouped[asset.status] = [];
}
grouped[asset.status].push(asset);
});
return grouped;
},
totalAssets: (state) => state.assets.length,
activeAssets: (state) =>
state.assets.filter((asset) => asset.status === 'active').length,
inactiveAssets: (state) =>
state.assets.filter((asset) => asset.status === 'inactive').length,
maintenanceAssets: (state) =>
state.assets.filter((asset) => asset.status === 'maintenance').length,
},
actions: {
async fetchAssets(params = {}) {
this.isLoading = true;
this.error = null;
try {
// Extract forceRefresh from params
const { forceRefresh, ...queryParams } = params;
// Clear assets if forcing refresh
if (forceRefresh) {
console.log('🔄 Force refreshing assets - clearing cache');
this.assets = [];
}
const finalParams = {
page: this.pagination.page,
limit: this.pagination.limit,
...queryParams,
// Strong cache busting
_cache_bust: Date.now(),
_random: Math.random(),
};
const response = await assetRepository.getAll(finalParams);
console.log('🔍 RAW API RESPONSE:', response);
console.log('🔍 ASSETS DATA FROM REPO:', response.data);
// Check if we're getting correct data from repository
if (response.data && response.data.length > 0) {
console.log('🔍 SAMPLE ASSET FROM REPO:', {
id: response.data[0].id,
name: response.data[0].name,
category: response.data[0].category_id?.name,
location: response.data[0].location_id?.name,
lastUpdated: response.data[0].date_updated
});
}
// Store the data
const newAssets = response.data || [];
this.assets = newAssets;
this.pagination.total = response.meta?.total_count || 0;
console.log('🔍 STORE ASSETS AFTER SET:', this.assets.length, 'items');
if (this.assets.length > 0) {
console.log('🔍 FIRST ASSET IN STORE:', {
id: this.assets[0].id,
name: this.assets[0].name,
category: this.assets[0].category_id?.name,
location: this.assets[0].location_id?.name,
lastUpdated: this.assets[0].date_updated
});
}
} catch (error) {
this.error = error.message;
console.error('Failed to fetch assets:', error);
throw error;
} finally {
this.isLoading = false;
}
},
async fetchAssetById(id, forceRefresh = false) {
this.isLoading = true;
this.error = null;
try {
console.log('📡 Fetching asset by ID:', { id, forceRefresh });
// Clear current asset if forcing refresh
if (forceRefresh) {
this.currentAsset = null;
console.log('🔄 Cleared current asset for fresh fetch');
}
// Force fresh database fetch with aggressive cache busting
const timestamp = Date.now();
const response = await assetRepository.getById(id, {
_cache_bust: timestamp,
_random: Math.random(),
_force_db: true,
_ts: timestamp,
// Add more cache busting techniques
_nocache: '1',
_version: timestamp
});
console.log('✅ Fresh asset fetched from database:', {
id: response.data.id,
name: response.data.name,
category: response.data.category_id?.name,
location: response.data.location_id?.name,
lastUpdated: response.data.date_updated
});
this.currentAsset = response.data;
// Also update the asset in the main assets array if it exists
const index = this.assets.findIndex(asset => asset.id === id);
if (index !== -1) {
console.log('🔄 Updating asset in main array with fresh data');
this.assets[index] = response.data;
}
return response.data;
} catch (error) {
this.error = error.message;
console.error('Failed to fetch asset:', error);
throw error;
} finally {
this.isLoading = false;
}
},
async createAsset(assetData) {
this.isLoading = true;
this.error = null;
try {
// OPTIMISTIC UPDATE: Add temporary asset to list immediately
const tempId = `temp_${Date.now()}`;
const optimisticAsset = {
...assetData,
id: tempId,
date_created: new Date().toISOString(),
date_updated: new Date().toISOString(),
_isOptimistic: true // Mark as optimistic for UI feedback
};
console.log('🚀 Optimistic create: Adding asset to frontend immediately');
this.assets.unshift(optimisticAsset);
// Make actual API call
console.log('📡 Creating asset on backend...');
const response = await assetRepository.create(assetData);
// Replace optimistic asset with real one
const tempIndex = this.assets.findIndex(asset => asset.id === tempId);
if (tempIndex !== -1) {
this.assets[tempIndex] = response.data;
}
console.log('✅ Asset creation completed successfully');
// Invalidate cache after creation
cacheService.onAssetCreated();
return response.data;
} catch (error) {
// ROLLBACK: Remove optimistic asset if creation fails
console.error('❌ Asset creation failed, removing optimistic entry');
this.assets = this.assets.filter(asset => !asset._isOptimistic);
this.error = error.message;
console.error('Failed to create asset:', error);
throw error;
} finally {
this.isLoading = false;
}
},
async updateAsset(id, assetData) {
this.isLoading = true;
this.error = null;
try {
console.log('🚀 STARTING OPTIMISTIC UPDATE:', { id, assetData });
// STEP 1: Immediate optimistic update for instant UI feedback
const assetIndex = this.assets.findIndex(asset => asset.id === id);
let originalAsset = null;
console.log('🔍 BEFORE OPTIMISTIC UPDATE:', {
assetIndex,
foundAsset: assetIndex !== -1,
currentAssetId: this.currentAsset?.id,
assetsCount: this.assets.length
});
if (assetIndex !== -1) {
// Store original data for rollback if needed
originalAsset = { ...this.assets[assetIndex] };
console.log('🔍 ORIGINAL ASSET DATA:', {
name: originalAsset.name,
id: originalAsset.id,
category: originalAsset.category_id,
location: originalAsset.location_id,
vendor: originalAsset.vendor_id
});
console.log('🔍 NEW DATA TO APPLY:', assetData);
// Apply optimistic update while preserving relationship data
const updatedAsset = {
...this.assets[assetIndex],
...assetData,
date_updated: new Date().toISOString()
};
// Preserve existing relationship objects if we only have IDs
if (assetData.category_id && typeof assetData.category_id === 'string') {
// Keep the existing category object if we only got an ID
updatedAsset.category_id = this.assets[assetIndex].category_id;
}
if (assetData.location_id && typeof assetData.location_id === 'string') {
// Keep the existing location object if we only got an ID
updatedAsset.location_id = this.assets[assetIndex].location_id;
}
if (assetData.vendor_id && typeof assetData.vendor_id === 'string') {
// Keep the existing vendor object if we only got an ID
updatedAsset.vendor_id = this.assets[assetIndex].vendor_id;
}
console.log('🔍 PRESERVING RELATIONSHIPS:', {
category: updatedAsset.category_id?.name,
location: updatedAsset.location_id?.name,
vendor: updatedAsset.vendor_id?.name
});
// Force Vue reactivity
this.assets.splice(assetIndex, 1, updatedAsset);
console.log('✨ Optimistic update applied to UI');
console.log('🔍 UPDATED ASSET IN ARRAY:', {
name: this.assets[assetIndex].name,
id: this.assets[assetIndex].id
});
} else {
console.log('❌ Asset not found in array - array is empty, will populate during background sync');
}
if (this.currentAsset?.id === id) {
console.log('🔍 UPDATING CURRENT ASSET:', this.currentAsset.name, '->', assetData.name);
const updatedCurrentAsset = {
...this.currentAsset,
...assetData,
date_updated: new Date().toISOString()
};
// Preserve existing relationship objects for current asset too
if (assetData.category_id && typeof assetData.category_id === 'string') {
updatedCurrentAsset.category_id = this.currentAsset.category_id;
}
if (assetData.location_id && typeof assetData.location_id === 'string') {
updatedCurrentAsset.location_id = this.currentAsset.location_id;
}
if (assetData.vendor_id && typeof assetData.vendor_id === 'string') {
updatedCurrentAsset.vendor_id = this.currentAsset.vendor_id;
}
this.currentAsset = updatedCurrentAsset;
console.log('✨ Optimistic update applied to current asset');
console.log('🔍 CURRENT ASSET AFTER UPDATE:', this.currentAsset.name);
} else {
console.log('❌ Current asset ID does not match or no current asset');
}
// STEP 2: Send update to backend (but don't wait for sync)
this.isLoading = false; // Clear loading immediately - user sees optimistic update
const updateResponse = await assetRepository.update(id, assetData);
console.log('✅ Backend update sent successfully');
// STEP 3: Background polling to ensure consistency (non-blocking)
this.pollForConsistency(id, assetData, originalAsset);
return { success: true };
} catch (error) {
console.error('❌ UPDATE FAILED:', error);
this.error = error.message;
// Rollback optimistic update on error
if (originalAsset && assetIndex !== -1) {
this.assets[assetIndex] = originalAsset;
console.log('↩️ Rolled back optimistic update due to error');
}
throw error;
} finally {
this.isLoading = false;
}
},
// Background polling to ensure data consistency (non-blocking)
async pollForConsistency(id, expectedData, originalAsset) {
console.log('🔄 Starting background consistency check...');
let attempts = 0;
const maxAttempts = 5;
const poll = async () => {
attempts++;
console.log(`🔍 Background check ${attempts}/${maxAttempts}...`);
try {
// Fetch fresh data quietly (using same cache-busting as main fetch)
const response = await assetRepository.getAll({
fields: ['*', 'category_id.*', 'location_id.*', 'vendor_id.*'],
_cache_bust: Date.now(),
_random: Math.random(),
_poll_attempt: attempts
});
console.log(`🔍 Background poll ${attempts} - got ${response.data?.length} assets`);
const serverAsset = response.data.find(a => a.id === id);
if (serverAsset) {
// Check if server data matches what we expect
const serverMatches = Object.keys(expectedData).every(key => {
return serverAsset[key] === expectedData[key];
});
if (serverMatches) {
console.log(`✅ Data consistency verified after ${attempts} attempts`);
// Update with complete server data (includes relationships)
const assetIndex = this.assets.findIndex(asset => asset.id === id);
if (assetIndex !== -1) {
this.assets[assetIndex] = serverAsset;
} else {
// If assets array is empty, populate it
console.log('🔄 Populating empty assets array with server data');
this.assets = response.data;
}
if (this.currentAsset?.id === id) {
this.currentAsset = serverAsset;
}
return; // Success - stop polling
}
}
if (attempts < maxAttempts) {
// Try again in 2 seconds
setTimeout(poll, 2000);
} else {
console.log('⚠️ Data consistency check timed out - keeping optimistic update');
}
} catch (error) {
console.warn(`⚠️ Background consistency check failed (attempt ${attempts}):`, error);
if (attempts < maxAttempts) {
setTimeout(poll, 2000);
}
}
};
// Start first check after 1 second
setTimeout(poll, 1000);
},
async deleteAsset(id) {
this.isLoading = true;
this.error = null;
try {
await assetRepository.delete(id);
this.assets = this.assets.filter((asset) => asset.id !== id);
if (this.currentAsset?.id === id) {
this.currentAsset = null;
}
// Invalidate cache after deletion
cacheService.onAssetDeleted();
} catch (error) {
this.error = error.message;
console.error('Failed to delete asset:', error);
throw error;
} finally {
this.isLoading = false;
}
},
async fetchCategories() {
try {
const { directusApi } = await import('../services/directus');
const response = await directusApi.get('/items/asset_categories', {
params: { fields: ['id', 'name', 'color'] }
});
this.categories = response.data.data || [];
} catch (error) {
console.error('Failed to fetch categories:', error);
throw error;
}
},
async fetchLocations() {
try {
const { directusApi } = await import('../services/directus');
const response = await directusApi.get('/items/locations', {
params: { fields: ['id', 'name', 'building', 'floor'] }
});
this.locations = response.data.data || [];
} catch (error) {
console.error('Failed to fetch locations:', error);
throw error;
}
},
setFilters(filters) {
this.filters = { ...this.filters, ...filters };
},
clearFilters() {
this.filters = {
search: '',
category: null,
location: null,
status: null,
};
},
setPagination(pagination) {
this.pagination = { ...this.pagination, ...pagination };
},
clearError() {
this.error = null;
},
// Ensure data consistency between currentAsset and assets array
syncAssetData(assetId) {
if (!this.currentAsset || this.currentAsset.id !== assetId) return;
const assetIndex = this.assets.findIndex(asset => asset.id === assetId);
if (assetIndex !== -1) {
// Sync currentAsset data to assets array
this.assets[assetIndex] = { ...this.currentAsset };
console.log('🔄 Synced currentAsset to assets array');
}
},
// Get the most up-to-date asset data (prefer currentAsset if it's the same ID)
getAssetById(id) {
if (this.currentAsset?.id === id) {
return this.currentAsset;
}
return this.assets.find(asset => asset.id === id) || null;
},
// Force refresh from server (use sparingly to avoid overriding optimistic updates)
async forceRefreshAssets() {
console.log('🔄 Force refreshing assets from server...');
this.assets = []; // Clear first to trigger reactivity
await this.fetchAssets();
console.log('✅ Force refresh completed');
},
},
});

109
frontend/src/stores/auth.js Normal file
View File

@ -0,0 +1,109 @@
// frontend/src/stores/auth.js
import { defineStore } from 'pinia';
import { AuthRepository } from '../repositories/AuthRepository';
const authRepository = new AuthRepository();
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('directus_token'),
refreshToken: localStorage.getItem('directus_refresh_token'),
isLoading: false,
error: null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
hasRole: (state) => (role) => state.user?.role?.name === role,
userPermissions: (state) => state.user?.role?.permissions || [],
},
actions: {
async login(email, password) {
this.isLoading = true;
this.error = null;
try {
const response = await authRepository.login(email, password);
const { access_token, refresh_token } = response.data;
this.token = access_token;
this.refreshToken = refresh_token;
localStorage.setItem('directus_token', access_token);
localStorage.setItem('directus_refresh_token', refresh_token);
await this.fetchUser();
return response;
} catch (error) {
this.error = error.message;
throw error;
} finally {
this.isLoading = false;
}
},
async logout() {
this.isLoading = true;
try {
await authRepository.logout();
} catch (error) {
console.error('Logout error:', error);
} finally {
this.user = null;
this.token = null;
this.refreshToken = null;
this.error = null;
this.isLoading = false;
localStorage.removeItem('directus_token');
localStorage.removeItem('directus_refresh_token');
}
},
async fetchUser() {
if (!this.token) return;
try {
const response = await authRepository.getMe();
this.user = response.data;
} catch (error) {
console.error('Failed to fetch user:', error);
this.logout();
}
},
async refreshToken() {
if (!this.refreshToken) return;
try {
const response = await authRepository.refresh();
const { access_token, refresh_token } = response.data;
this.token = access_token;
this.refreshToken = refresh_token;
localStorage.setItem('directus_token', access_token);
localStorage.setItem('directus_refresh_token', refresh_token);
return response;
} catch (error) {
console.error('Token refresh failed:', error);
this.logout();
throw error;
}
},
clearError() {
this.error = null;
},
},
persist: {
key: 'auth',
storage: localStorage,
paths: ['token', 'refreshToken', 'user'],
},
});

View File

@ -0,0 +1,14 @@
// frontend/src/stores/index.js
import { createPinia } from 'pinia';
import piniaPluginPersistedState from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedState);
export default pinia;
// Export all stores
export { useAuthStore } from './auth';
export { useAssetsStore } from './assets';
export { useSubscriptionStore } from './subscription';
export { useUIStore } from './ui';

121
frontend/src/stores/ui.js Normal file
View File

@ -0,0 +1,121 @@
// frontend/src/stores/ui.js
import { defineStore } from 'pinia';
export const useUIStore = defineStore('ui', {
state: () => ({
isLoading: false,
drawer: false,
sidebarPersistent: false, // Controls whether sidebar stays open
sidebarCollapsed: false, // Controls collapsed state when persistent
snackbar: {
show: false,
message: '',
color: 'info',
timeout: 4000,
},
theme: 'light',
pageTitle: 'Enterprise Asset Management',
}),
getters: {
isDarkTheme: (state) => state.theme === 'dark',
sidebarWidth: (state) => {
if (!state.sidebarPersistent) return 280;
return state.sidebarCollapsed ? 56 : 280;
},
},
actions: {
setLoading(loading) {
this.isLoading = loading;
},
toggleDrawer() {
this.drawer = !this.drawer;
},
setDrawer(value) {
this.drawer = value;
},
showSnackbar({ message, color = 'info', timeout = 4000 }) {
this.snackbar = {
show: true,
message,
color,
timeout,
};
},
hideSnackbar() {
this.snackbar.show = false;
},
showSuccess(message) {
this.showSnackbar({ message, color: 'success' });
},
showError(message) {
this.showSnackbar({ message, color: 'error', timeout: 6000 });
},
showWarning(message) {
this.showSnackbar({ message, color: 'warning' });
},
showInfo(message) {
this.showSnackbar({ message, color: 'info' });
},
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
},
setTheme(theme) {
this.theme = theme;
},
setPageTitle(title) {
this.pageTitle = title;
document.title = `${title} - Enterprise Asset Management`;
},
toggleSidebarPersistent() {
this.sidebarPersistent = !this.sidebarPersistent;
if (!this.sidebarPersistent) {
this.sidebarCollapsed = false;
this.drawer = false;
} else {
this.drawer = true;
}
},
setSidebarPersistent(persistent) {
this.sidebarPersistent = persistent;
if (!persistent) {
this.sidebarCollapsed = false;
this.drawer = false;
} else {
this.drawer = true;
}
},
toggleSidebarCollapsed() {
if (this.sidebarPersistent) {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
},
setSidebarCollapsed(collapsed) {
if (this.sidebarPersistent) {
this.sidebarCollapsed = collapsed;
}
},
},
persist: {
key: 'ui',
storage: localStorage,
paths: ['theme', 'drawer', 'sidebarPersistent', 'sidebarCollapsed'],
},
});

View File

@ -0,0 +1,205 @@
<!-- frontend/src/views/AddAsset.vue -->
<template>
<div class="add-asset-page">
<!-- Page Header -->
<div class="page-header fluent-entrance mb-8">
<div class="d-flex align-center mb-4">
<v-btn
icon
variant="text"
color="neutral-primary"
class="mr-3"
@click="$router.go(-1)"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<div>
<h1 class="text-title1 mb-1">Add New Asset</h1>
<p class="text-body1" style="color: #605E5C;">
Create a new asset record for tracking and management
</p>
</div>
</div>
</div>
<!-- Form Container -->
<v-row>
<v-col cols="12" lg="8" xl="6">
<v-card class="form-card fluent-layer-1" elevation="0">
<v-card-text class="pa-8">
<AssetForm
mode="create"
:is-submitting="isSubmitting"
@submit="handleFormSubmit"
@cancel="handleFormCancel"
/>
</v-card-text>
</v-card>
</v-col>
<!-- Help Panel -->
<v-col cols="12" lg="4" xl="6">
<v-card class="help-panel fluent-layer-1" elevation="0">
<v-card-text class="pa-6">
<h3 class="text-title3 mb-4" style="color: #323130;">
<v-icon color="primary" class="mr-2">mdi-help-circle</v-icon>
Quick Help
</h3>
<div class="help-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Asset Identifier</h4>
<p class="text-caption1" style="color: #605E5C;">
Use a unique identifier like AST-001, EQ-2024-001, or follow your organization's naming convention.
</p>
</div>
<div class="help-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Categories</h4>
<p class="text-caption1" style="color: #605E5C;">
Choose the most appropriate category. This helps with reporting and organization.
</p>
</div>
<div class="help-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Financial Information</h4>
<p class="text-caption1" style="color: #605E5C;">
Include purchase price and current value for depreciation tracking and insurance purposes.
</p>
</div>
<div class="help-section">
<h4 class="text-body1 font-weight-bold mb-2">Image Upload</h4>
<p class="text-caption1" style="color: #605E5C;">
Add a clear photo of the asset for easy identification. Supported formats: JPG, PNG, WebP.
</p>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUIStore } from '../stores/ui';
import { useAssetsStore } from '../stores/assets';
import AssetForm from '../components/assets/AssetForm.vue';
export default {
name: 'AddAsset',
components: {
AssetForm,
},
setup() {
const router = useRouter();
const uiStore = useUIStore();
const assetsStore = useAssetsStore();
const isSubmitting = ref(false);
// Handle form submission
const handleFormSubmit = async (formData) => {
isSubmitting.value = true;
try {
// Get organization ID from authenticated user
const { authService } = await import('../services/auth');
const currentUser = await authService.getCurrentUser();
if (!currentUser.organization_id) {
throw new Error('User is not associated with an organization');
}
const organizationId = typeof currentUser.organization_id === 'object'
? currentUser.organization_id.id
: currentUser.organization_id;
console.log('🏢 Using organization ID from user context:', organizationId);
// Add organization context to form data
const assetData = {
organization_id: organizationId,
...formData,
};
// Create the asset using the store
await assetsStore.createAsset(assetData);
uiStore.showSuccess('Asset created successfully!');
router.push('/assets');
} catch (error) {
uiStore.showError('Failed to create asset. Please try again.');
console.error('Error creating asset:', error);
} finally {
isSubmitting.value = false;
}
};
// Handle form cancellation
const handleFormCancel = () => {
router.go(-1);
};
onMounted(() => {
uiStore.setPageTitle('Add Asset');
});
return {
isSubmitting,
handleFormSubmit,
handleFormCancel,
};
},
};
</script>
<style scoped>
.add-asset-page {
padding: 24px;
animation: fluent-entrance 0.3s cubic-bezier(0.1, 0.9, 0.2, 1);
max-width: 1400px;
margin: 0 auto;
}
.form-card {
border: 1px solid #E1DFDD;
border-radius: 4px;
background: #FFFFFF;
transition: all 0.2s cubic-bezier(0.1, 0.9, 0.2, 1);
}
.help-panel {
border: 1px solid #E1DFDD;
border-radius: 4px;
background: #FFFFFF;
position: sticky;
top: 24px;
}
.help-section {
h4 {
color: #323130;
}
p {
line-height: 1.5;
}
}
@media (max-width: 768px) {
.add-asset-page {
padding: 16px;
}
.form-card .v-card-text {
padding: 24px 16px;
}
.help-panel {
position: static;
margin-top: 24px;
}
}
</style>

View File

@ -0,0 +1,621 @@
<!-- frontend/src/views/AssetDetail.vue -->
<template>
<div class="asset-detail-page">
<!-- Page Header -->
<div class="page-header fluent-entrance mb-8">
<div class="d-flex align-center mb-4">
<v-btn
icon
variant="text"
color="neutral-primary"
class="mr-3"
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<div class="flex-grow-1">
<h1 class="text-title1 mb-1">{{ asset?.name || 'Loading...' }}</h1>
<p class="text-body1" style="color: #605E5C;">
Asset ID: {{ asset?.asset_identifier || '-' }}
</p>
</div>
<div class="d-flex gap-3">
<v-btn
v-if="canEditAssets && asset"
color="primary"
variant="outlined"
prepend-icon="mdi-pencil"
@click="editAsset"
>
Edit Asset
</v-btn>
<v-btn
v-if="asset"
variant="outlined"
prepend-icon="mdi-qrcode"
@click="showQRCode"
>
QR Code
</v-btn>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="d-flex justify-center align-center" style="min-height: 400px;">
<v-progress-circular indeterminate size="64" />
</div>
<!-- Error State -->
<v-alert v-else-if="error" type="error" variant="tonal" class="mb-6">
<v-alert-title>Error Loading Asset</v-alert-title>
{{ error }}
<template #append>
<v-btn variant="text" @click="loadAsset">Retry</v-btn>
</template>
</v-alert>
<!-- Asset Details -->
<div v-else-if="asset" class="asset-content">
<v-row>
<!-- Main Content -->
<v-col cols="12" lg="8">
<!-- Basic Information Card -->
<v-card class="fluent-layer-1 mb-6" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-information</v-icon>
Basic Information
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Asset Name</label>
<div class="info-value">{{ asset.name }}</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Asset Identifier</label>
<div class="info-value">{{ asset.asset_identifier }}</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Category</label>
<div class="info-value d-flex align-center">
<v-chip
:color="asset.category_id?.color || '#9E9E9E'"
size="small"
class="mr-2"
>
{{ asset.category_id?.name || 'Unknown' }}
</v-chip>
</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Status</label>
<div class="info-value">
<v-chip
:color="getStatusColor(asset.status)"
size="small"
variant="tonal"
>
{{ formatStatus(asset.status) }}
</v-chip>
</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Location</label>
<div class="info-value">
<div>{{ asset.location_id?.name || 'Unknown' }}</div>
<div v-if="asset.location_id?.building" class="text-caption" style="color: #605E5C;">
{{ asset.location_id.building }}
<span v-if="asset.location_id.floor"> - {{ asset.location_id.floor }}</span>
</div>
</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Vendor</label>
<div class="info-value">{{ asset.vendor_id?.name || 'Not specified' }}</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Manufacturer</label>
<div class="info-value">{{ asset.manufacturer || 'Not specified' }}</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Model Number</label>
<div class="info-value">{{ asset.model_number || 'Not specified' }}</div>
</div>
</v-col>
</v-row>
<div v-if="asset.description" class="info-item">
<label class="info-label">Description</label>
<div class="info-value">{{ asset.description }}</div>
</div>
</v-card-text>
</v-card>
<!-- Financial Information Card -->
<v-card class="fluent-layer-1 mb-6" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-currency-usd</v-icon>
Financial Information
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Acquisition Cost</label>
<div class="info-value text-h6">
{{ formatCurrency(asset.acquisition_cost) }}
</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Net Book Value</label>
<div class="info-value">
{{ formatCurrency(asset.net_book_value) }}
</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Acquisition Date</label>
<div class="info-value">
{{ formatDate(asset.acquisition_date) }}
</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Depreciation Method</label>
<div class="info-value">
{{ formatDepreciationMethod(asset.depreciation_method) }}
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- Technical Details Card -->
<v-card class="fluent-layer-1 mb-6" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-cog</v-icon>
Technical Details
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Serial Number</label>
<div class="info-value">{{ asset.serial_number || 'Not specified' }}</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Condition Rating</label>
<div class="info-value">
<v-chip
:color="getConditionColor(asset.condition_rating)"
size="small"
variant="tonal"
>
{{ formatCondition(asset.condition_rating) }}
</v-chip>
</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="info-item mb-4">
<label class="info-label">Warranty Start</label>
<div class="info-value">
{{ formatDate(asset.warranty_start_date) }}
</div>
</div>
<div class="info-item mb-4">
<label class="info-label">Warranty Expiration</label>
<div class="info-value">
{{ formatDate(asset.warranty_expiration_date) }}
<v-chip
v-if="isWarrantyExpired(asset.warranty_expiration_date)"
color="error"
size="x-small"
class="ml-2"
>
Expired
</v-chip>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<!-- Sidebar -->
<v-col cols="12" lg="4">
<!-- Asset Image Card -->
<v-card class="fluent-layer-1 mb-6" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-image</v-icon>
Asset Image
</v-card-title>
<v-card-text>
<div class="asset-image-container">
<v-img
v-if="getAssetImageUrl(asset)"
:src="getAssetImageUrl(asset)"
aspect-ratio="1"
cover
class="asset-image"
@click="showImageDialog = true"
/>
<div v-else class="no-image-placeholder">
<v-icon size="64" color="grey-lighten-1">mdi-image-off</v-icon>
<div class="text-caption mt-2" style="color: #605E5C;">
No image available
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- Quick Stats Card -->
<v-card class="fluent-layer-1 mb-6" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-chart-line</v-icon>
Quick Stats
</v-card-title>
<v-card-text>
<div class="stat-item mb-3">
<div class="stat-label">Age</div>
<div class="stat-value">{{ calculateAge(asset.acquisition_date) }}</div>
</div>
<div class="stat-item mb-3">
<div class="stat-label">Depreciation Rate</div>
<div class="stat-value">{{ asset.depreciation_rate || 0 }}%</div>
</div>
<div class="stat-item mb-3">
<div class="stat-label">Expected Life</div>
<div class="stat-value">{{ asset.expected_useful_life || 0 }} months</div>
</div>
<div class="stat-item">
<div class="stat-label">Created</div>
<div class="stat-value">{{ formatDate(asset.date_created) }}</div>
</div>
</v-card-text>
</v-card>
<!-- QR Code Card -->
<v-card v-if="asset.asset_qr_codes && asset.asset_qr_codes.length > 0" class="fluent-layer-1" elevation="0">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-2">mdi-qrcode</v-icon>
QR Code
</v-card-title>
<v-card-text class="text-center">
<v-btn
variant="outlined"
prepend-icon="mdi-qrcode"
@click="showQRCode"
block
>
View QR Code
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<!-- Image Dialog -->
<v-dialog v-model="showImageDialog" max-width="800">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
{{ asset?.name }} - Image
<v-btn icon @click="showImageDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-img
v-if="getAssetImageUrl(asset)"
:src="getAssetImageUrl(asset, { quality: 90 })"
contain
max-height="600"
/>
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAssetsStore } from '../stores/assets';
import { useUIStore } from '../stores/ui';
import { fileUploadService } from '../services/fileUpload';
export default {
name: 'AssetDetail',
setup() {
const route = useRoute();
const router = useRouter();
const assetsStore = useAssetsStore();
const uiStore = useUIStore();
const isLoading = ref(true);
const error = ref(null);
const showImageDialog = ref(false);
const canEditAssets = ref(false);
const assetId = computed(() => route.params.id);
// Use asset from store (automatically reactive to optimistic updates)
const asset = computed(() => {
console.log('🔍 AssetDetail computed - current asset:', {
id: assetsStore.currentAsset?.id,
name: assetsStore.currentAsset?.name,
lastUpdated: assetsStore.currentAsset?.date_updated
});
return assetsStore.currentAsset;
});
// Load asset data
const loadAsset = async () => {
if (!assetId.value) {
error.value = 'Invalid asset ID';
isLoading.value = false;
return;
}
try {
isLoading.value = true;
error.value = null;
// Load permissions first
const { permissionsService } = await import('../services/permissions');
await permissionsService.initialize();
canEditAssets.value = permissionsService.canEditAssets();
// Fetch asset details (store will update asset computed automatically)
console.log('📡 Fetching asset data for ID:', assetId.value);
const assetData = await assetsStore.fetchAssetById(assetId.value);
console.log('✅ Asset data loaded:', {
name: assetData.name,
lastUpdated: assetData.date_updated,
imageUrl: assetData.image_url
});
uiStore.setPageTitle(`Asset: ${assetData.name}`);
} catch (err) {
console.error('Failed to load asset:', err);
error.value = err.message || 'Failed to load asset details';
} finally {
isLoading.value = false;
}
};
// Navigation functions
const goBack = () => {
router.push('/assets');
};
const editAsset = () => {
router.push(`/assets/${assetId.value}/edit`);
};
const showQRCode = () => {
// TODO: Implement QR code display
uiStore.showInfo('QR Code functionality coming soon!');
};
// Formatting functions
const formatCurrency = (amount) => {
if (!amount && amount !== 0) return 'Not specified';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
const formatDate = (date) => {
if (!date) return 'Not specified';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const formatStatus = (status) => {
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Unknown';
};
const formatCondition = (condition) => {
return condition ? condition.charAt(0).toUpperCase() + condition.slice(1) : 'Unknown';
};
const formatDepreciationMethod = (method) => {
const methods = {
'straight_line': 'Straight Line',
'declining_balance': 'Declining Balance',
'sum_of_years': 'Sum of Years',
'units_of_production': 'Units of Production'
};
return methods[method] || method || 'Not specified';
};
const getStatusColor = (status) => {
const colors = {
'active': 'success',
'inactive': 'default',
'maintenance': 'warning',
'retired': 'error'
};
return colors[status] || 'default';
};
const getConditionColor = (condition) => {
const colors = {
'excellent': 'success',
'good': 'info',
'fair': 'warning',
'poor': 'error',
'critical': 'error'
};
return colors[condition] || 'default';
};
const getAssetImageUrl = (asset, params = {}) => {
if (!asset?.image_url) return null;
const fileId = typeof asset.image_url === 'object'
? asset.image_url.id
: asset.image_url;
return fileId ? fileUploadService.getImageUrl(fileId, {
width: 400,
height: 400,
fit: 'cover',
quality: 80,
...params
}) : null;
};
const calculateAge = (acquisitionDate) => {
if (!acquisitionDate) return 'Unknown';
const now = new Date();
const acquired = new Date(acquisitionDate);
const diffTime = Math.abs(now - acquired);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 30) {
return `${diffDays} days`;
} else if (diffDays < 365) {
return `${Math.floor(diffDays / 30)} months`;
} else {
return `${Math.floor(diffDays / 365)} years`;
}
};
const isWarrantyExpired = (warrantyDate) => {
if (!warrantyDate) return false;
return new Date(warrantyDate) < new Date();
};
// Watch for route changes (simplified - optimistic updates handle most cases)
watch(assetId, loadAsset);
onMounted(loadAsset);
return {
asset,
isLoading,
error,
showImageDialog,
canEditAssets,
goBack,
editAsset,
showQRCode,
loadAsset,
formatCurrency,
formatDate,
formatStatus,
formatCondition,
formatDepreciationMethod,
getStatusColor,
getConditionColor,
getAssetImageUrl,
calculateAge,
isWarrantyExpired
};
}
};
</script>
<style scoped>
.asset-detail-page {
padding: 24px;
animation: fluent-entrance 0.3s cubic-bezier(0.1, 0.9, 0.2, 1);
max-width: 1400px;
margin: 0 auto;
}
.fluent-layer-1 {
border: 1px solid #E1DFDD;
border-radius: 4px;
background: #FFFFFF;
transition: all 0.2s cubic-bezier(0.1, 0.9, 0.2, 1);
}
.info-item {
.info-label {
font-size: 12px;
font-weight: 600;
color: #605E5C;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
display: block;
}
.info-value {
font-size: 14px;
color: #323130;
line-height: 1.4;
}
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
.stat-label {
font-size: 14px;
color: #605E5C;
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: #323130;
}
}
.asset-image-container {
position: relative;
border-radius: 4px;
overflow: hidden;
.asset-image {
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.02);
}
}
.no-image-placeholder {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #F8F9FA;
border: 2px dashed #E1DFDD;
border-radius: 4px;
}
}
@media (max-width: 768px) {
.asset-detail-page {
padding: 16px;
}
.page-header .d-flex {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
}
</style>

View File

@ -0,0 +1,597 @@
<!-- frontend/src/views/Assets.vue -->
<template>
<div class="assets-page">
<!-- Page header with shadcn/ui styling -->
<div class="page-header fluent-entrance mb-8">
<div class="d-flex justify-space-between align-center">
<div>
<h1 class="text-title1 mb-2">Asset Management</h1>
<p class="text-body1 text-muted">
Manage and track all your organization's assets
</p>
</div>
<v-btn
v-if="canCreateAssets"
color="primary"
variant="elevated"
size="large"
prepend-icon="mdi-plus"
class="shadow"
@click="createAsset"
:disabled="!canAddAssets"
>
Add Asset
</v-btn>
</div>
</div>
<!-- Filters and search -->
<v-row class="mb-4">
<v-col cols="12" md="4">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search assets..."
variant="outlined"
density="compact"
clearable
@input="onSearchChange"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="selectedCategory"
:items="categories"
item-title="name"
item-value="id"
label="Category"
variant="outlined"
density="compact"
clearable
@update:model-value="onFilterChange"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="selectedLocation"
:items="locations"
item-title="name"
item-value="id"
label="Location"
variant="outlined"
density="compact"
clearable
@update:model-value="onFilterChange"
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="selectedStatus"
:items="statusOptions"
label="Status"
variant="outlined"
density="compact"
clearable
@update:model-value="onFilterChange"
/>
</v-col>
<v-col cols="12" md="2">
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
>
<v-btn value="grid" icon="mdi-view-grid" />
<v-btn value="list" icon="mdi-view-list" />
</v-btn-toggle>
</v-col>
</v-row>
<!-- Asset limit warning -->
<v-alert v-if="isNearLimit" type="warning" variant="tonal" class="mb-4">
<v-alert-title>Asset Limit Warning</v-alert-title>
You're using {{ assetsUsed }} of {{ assetLimit }} assets allowed in your
{{ currentPlan }} plan.
<template #append>
<v-btn variant="text" size="small" @click="goToSubscription">
Upgrade Plan
</v-btn>
</template>
</v-alert>
<!-- Grid view -->
<v-row v-if="viewMode === 'grid'">
<v-col
v-for="asset in displayedAssets"
:key="asset.id"
cols="12"
sm="6"
md="4"
lg="3"
>
<AssetCard :asset="asset" @click="viewAsset(asset.id)" />
</v-col>
<!-- Empty state -->
<v-col v-if="displayedAssets.length === 0 && !isLoading" cols="12">
<EmptyState
icon="mdi-package-variant"
title="No assets found"
:subtitle="
hasFilters
? 'Try adjusting your search filters'
: 'Get started by adding your first asset'
"
:action="!hasFilters ? 'Add Asset' : null"
@action="createAsset"
/>
</v-col>
</v-row>
<!-- List view -->
<v-card v-else class="shadow">
<v-data-table
:headers="tableHeaders"
:items="displayedAssets"
:loading="isLoading"
:items-per-page="25"
:search="search"
class="elevation-0"
>
<template #item.name="{ item }">
<div class="d-flex align-center asset-row" @click="viewAsset(item.id)">
<v-avatar
size="32"
class="mr-3"
:color="getAssetImageUrl(item) ? 'transparent' : 'grey-lighten-2'"
>
<v-img
v-if="getAssetImageUrl(item)"
:src="getAssetImageUrl(item)"
cover
/>
<v-icon v-else>mdi-package-variant</v-icon>
</v-avatar>
<div>
<div class="font-weight-medium asset-name">{{ item.name }}</div>
<div class="text-caption text-grey-darken-1">
{{ item.asset_identifier }}
</div>
</div>
</div>
</template>
<template #item.category="{ item }">
<v-chip
:color="item.category_color || 'primary'"
size="small"
variant="tonal"
>
{{ item.category }}
</v-chip>
</template>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
:class="`text-status-${item.status}`"
>
{{ item.status }}
</v-chip>
</template>
<template #item.acquisition_cost="{ item }">
{{ formatCurrency(item.acquisition_cost) }}
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="viewAsset(item.id)"
/>
<v-btn
icon="mdi-qrcode"
size="small"
variant="text"
@click="showQRCode(item)"
/>
<v-btn
v-if="canEditAssets"
icon="mdi-pencil"
size="small"
variant="text"
@click="editAsset(item.id)"
/>
</div>
</template>
<template #no-data>
<EmptyState
icon="mdi-package-variant"
title="No assets found"
:subtitle="
hasFilters
? 'Try adjusting your search filters'
: 'Get started by adding your first asset'
"
:action="!hasFilters ? 'Add Asset' : null"
@action="createAsset"
/>
</template>
</v-data-table>
</v-card>
<!-- Loading overlay -->
<v-overlay
v-model="isLoading"
class="align-center justify-center"
contained
>
<v-progress-circular indeterminate size="64" />
</v-overlay>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAssetsStore } from '../stores/assets';
import { useUIStore } from '../stores/ui';
import AssetCard from '../components/assets/AssetCard.vue';
import EmptyState from '../components/common/EmptyState.vue';
import { fileUploadService } from '../services/fileUpload';
export default {
name: 'Assets',
components: {
AssetCard,
EmptyState,
},
setup() {
const router = useRouter();
const route = useRoute();
const assetsStore = useAssetsStore();
const uiStore = useUIStore();
const search = ref(route.query.search || '');
const selectedCategory = ref(null);
const selectedLocation = ref(null);
const selectedStatus = ref(null);
const viewMode = ref('list');
// Permission-based reactive properties
const canCreateAssets = ref(false);
const canViewAssets = ref(false);
const canEditAssets = ref(false);
const canDeleteAssets = ref(false);
// Simple reactive reference to store assets
const assets = computed(() => {
console.log('🔍 ASSETS COMPUTED TRIGGERED - count:', assetsStore.assets.length);
console.log('🔍 ASSETS COMPUTED - first asset:', assetsStore.assets[0]);
return assetsStore.assets;
});
const categories = computed(() => assetsStore.categories);
const locations = computed(() => assetsStore.locations);
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Inactive', value: 'inactive' },
{ title: 'Maintenance', value: 'maintenance' },
{ title: 'Retired', value: 'retired' },
];
const tableHeaders = [
{ title: 'Asset', value: 'name', sortable: true },
{ title: 'Category', value: 'category', sortable: true },
{ title: 'Location', value: 'location', sortable: true },
{ title: 'Status', value: 'status', sortable: true },
{ title: 'Value', value: 'acquisition_cost', sortable: true },
{ title: 'Actions', value: 'actions', sortable: false, width: 120 },
];
const displayedAssets = computed(() => {
let filtered = assets.value.map((asset) => ({
...asset,
// Add category and location names for display
category: asset.category_id?.name || 'Unknown',
category_color: asset.category_id?.color || '#9E9E9E',
location: asset.location_id
? `${asset.location_id.name}${asset.location_id.building ? ` - ${asset.location_id.building}` : ''}`
: 'Unknown',
}));
// Search filter
if (search.value) {
const searchTerm = search.value.toLowerCase();
filtered = filtered.filter(
(asset) =>
asset.name.toLowerCase().includes(searchTerm) ||
asset.asset_identifier.toLowerCase().includes(searchTerm) ||
asset.serial_number?.toLowerCase().includes(searchTerm)
);
}
// Category filter
if (selectedCategory.value) {
filtered = filtered.filter(
(asset) => asset.category_id?.id === selectedCategory.value
);
}
// Location filter
if (selectedLocation.value) {
filtered = filtered.filter(
(asset) => asset.location_id?.id === selectedLocation.value
);
}
// Status filter
if (selectedStatus.value) {
filtered = filtered.filter(
(asset) => asset.status === selectedStatus.value
);
}
return filtered;
});
const hasFilters = computed(() => {
return (
search.value ||
selectedCategory.value ||
selectedLocation.value ||
selectedStatus.value
);
});
const isLoading = computed(() => assetsStore.isLoading);
// Subscription data based on real assets
const currentPlan = computed(() => 'Enterprise Plan'); // TODO: Get from organization data
const assetsUsed = computed(() => assets.value.length);
const assetLimit = computed(() => 1000); // TODO: Get from subscription plan
const canAddAssets = computed(() => assetsUsed.value < assetLimit.value);
const isNearLimit = computed(
() => assetsUsed.value / assetLimit.value >= 0.8
);
const createAsset = () => {
if (!canAddAssets.value) {
uiStore.showWarning(
'Asset limit reached. Please upgrade your plan to add more assets.'
);
return;
}
router.push('/assets/add');
};
const viewAsset = (id) => {
router.push(`/assets/${id}`);
};
const editAsset = (id) => {
router.push(`/assets/${id}/edit`);
};
const showQRCode = (asset) => {
uiStore.showInfo(`QR Code for ${asset.name} - Feature coming soon!`);
};
const goToSubscription = () => {
router.push('/subscription');
};
const onSearchChange = () => {
// Debounce search if needed
};
const onFilterChange = () => {
// Update URL query params if needed
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const getStatusColor = (status) => {
const colors = {
active: 'success',
inactive: 'default',
maintenance: 'warning',
retired: 'error',
};
return colors[status] || 'default';
};
const getAssetImageUrl = (asset) => {
if (!asset.image_url) return null;
// Handle both nested object format and direct ID
const fileId =
typeof asset.image_url === 'object'
? asset.image_url.id
: asset.image_url;
return fileId ? fileUploadService.getThumbnailUrl(fileId, 32) : null;
};
// Load data from backend
const loadData = async () => {
try {
// Use environment-specific authentication
const { authService } = await import('../services/auth');
const { permissionsService } = await import('../services/permissions');
// Ensure user is authenticated
const isAuthenticated = await authService.ensureAuthenticated();
if (!isAuthenticated) {
uiStore.showError('Authentication required. Please log in.');
return;
}
// Initialize permissions
await permissionsService.initialize();
// Set permission-based reactive properties
canCreateAssets.value = permissionsService.canCreateAssets();
canViewAssets.value = permissionsService.canViewAssets();
canEditAssets.value = permissionsService.canEditAssets();
canDeleteAssets.value = permissionsService.canDeleteAssets();
console.log(
'🔐 Permissions loaded:',
permissionsService.getPermissionSummary()
);
// Only fetch if we don't have assets (preserve optimistic updates)
const hasAssets = assetsStore.assets.length > 0;
if (!hasAssets) {
console.log('📡 Fetching fresh assets data...');
await assetsStore.fetchAssets({ forceRefresh: true });
if (assetsStore.assets.length > 0) {
uiStore.showSuccess(
`Loaded ${assetsStore.assets.length} assets from database`
);
}
} else {
console.log('✅ Preserving existing assets data (may include optimistic updates)');
}
// Always fetch categories and locations (they're small and don't change often)
await assetsStore.fetchCategories();
await assetsStore.fetchLocations();
console.log('Assets data ready:', {
assets: assetsStore.assets.length,
categories: assetsStore.categories.length,
locations: assetsStore.locations.length,
});
} catch (error) {
console.error('Failed to load assets data:', error);
uiStore.showError(
'Failed to load assets data. Please refresh the page.'
);
}
};
onMounted(async () => {
uiStore.setPageTitle('Assets');
await loadData();
});
// With optimistic updates, we don't need aggressive route watching
// The state is already updated when changes are made
return {
search,
selectedCategory,
selectedLocation,
selectedStatus,
viewMode,
categories,
locations,
statusOptions,
tableHeaders,
displayedAssets,
hasFilters,
isLoading,
currentPlan,
assetsUsed,
assetLimit,
canAddAssets,
isNearLimit,
canCreateAssets,
canViewAssets,
canEditAssets,
canDeleteAssets,
createAsset,
viewAsset,
editAsset,
showQRCode,
goToSubscription,
onSearchChange,
onFilterChange,
formatCurrency,
getStatusColor,
getAssetImageUrl,
};
},
};
</script>
<style scoped>
.assets-page {
padding: 24px;
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.page-header {
margin-bottom: 32px;
}
@media (max-width: 768px) {
.assets-page {
padding: 16px;
}
}
.asset-row {
cursor: pointer;
padding: 8px;
margin: -8px;
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background-color: #F8FAFC;
transform: translateY(-1px);
}
.asset-name {
color: #0F172A;
font-weight: 500;
transition: color 0.15s ease;
}
&:hover .asset-name {
color: #3B82F6;
text-decoration: underline;
}
}
/* shadcn/ui style status chips */
:deep(.v-chip.text-status-active) {
background: #DCFCE7 !important;
color: #166534 !important;
border: 1px solid #BBF7D0;
}
:deep(.v-chip.text-status-inactive) {
background: #F1F5F9 !important;
color: #64748B !important;
border: 1px solid #E2E8F0;
}
:deep(.v-chip.text-status-maintenance) {
background: #FEF3C7 !important;
color: #92400E !important;
border: 1px solid #FDE68A;
}
:deep(.v-chip.text-status-retired) {
background: #FEE2E2 !important;
color: #991B1B !important;
border: 1px solid #FECACA;
}
</style>

View File

@ -0,0 +1,304 @@
<!-- frontend/src/views/Dashboard.vue -->
<template>
<div class="dashboard">
<!-- Page header with Fluent Design -->
<div class="page-header fluent-entrance mb-8">
<div class="d-flex justify-space-between align-center">
<div>
<h1 class="text-title1 mb-2">Dashboard</h1>
<p class="text-body1" style="color: #605E5C;">
Welcome back! Here's an overview of your assets.
</p>
</div>
<v-btn
color="primary"
variant="elevated"
size="large"
prepend-icon="mdi-plus"
class="fluent-layer-2"
@click="createAsset"
>
Add Asset
</v-btn>
</div>
</div>
<!-- Stats cards with Fluent Design -->
<v-row class="mb-8">
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card fluent-layer-1 pa-6 text-center" elevation="0">
<v-icon size="40" color="primary" class="mb-3">
mdi-package-variant
</v-icon>
<div class="text-title2 font-weight-bold mb-1" style="color: #0078D4;">
{{ totalAssets }}
</div>
<div class="text-caption1" style="color: #605E5C;">Total Assets</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card fluent-layer-1 pa-6 text-center" elevation="0">
<v-icon size="40" color="success" class="mb-3">
mdi-check-circle
</v-icon>
<div class="text-title2 font-weight-bold mb-1" style="color: #107C10;">
{{ activeAssets }}
</div>
<div class="text-caption1" style="color: #605E5C;">Active Assets</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card fluent-layer-1 pa-6 text-center" elevation="0">
<v-icon size="40" color="warning" class="mb-3">mdi-wrench</v-icon>
<div class="text-title2 font-weight-bold mb-1" style="color: #FFB900;">
{{ maintenanceAssets }}
</div>
<div class="text-caption1" style="color: #605E5C;">In Maintenance</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card fluent-layer-1 pa-6 text-center" elevation="0">
<v-icon size="40" color="info" class="mb-3">
mdi-currency-usd
</v-icon>
<div class="text-title2 font-weight-bold mb-1" style="color: #0078D4;">
{{ formatCurrency(totalValue) }}
</div>
<div class="text-caption1" style="color: #605E5C;">Total Value</div>
</v-card>
</v-col>
</v-row>
<!-- Quick actions and recent activity -->
<v-row>
<!-- Quick actions -->
<v-col cols="12" md="4">
<v-card class="pa-4">
<v-card-title class="text-h6 pa-0 mb-4"> Quick Actions </v-card-title>
<div class="d-flex flex-column ga-2">
<v-btn
variant="outlined"
block
prepend-icon="mdi-plus"
@click="createAsset"
>
Add New Asset
</v-btn>
<v-btn
variant="outlined"
block
prepend-icon="mdi-wrench"
@click="createWorkOrder"
>
Create Work Order
</v-btn>
<v-btn
variant="outlined"
block
prepend-icon="mdi-qrcode"
@click="scanQRCode"
>
Scan QR Code
</v-btn>
<v-btn
variant="outlined"
block
prepend-icon="mdi-chart-bar"
@click="viewReports"
>
View Reports
</v-btn>
</div>
</v-card>
</v-col>
<!-- Recent assets -->
<v-col cols="12" md="8">
<v-card class="pa-4">
<v-card-title
class="text-h6 pa-0 mb-4 d-flex justify-space-between align-center"
>
Recent Assets
<v-btn variant="text" size="small" @click="viewAllAssets">
View All
</v-btn>
</v-card-title>
<v-data-table
:headers="assetHeaders"
:items="recentAssets"
:loading="isLoading"
hide-default-footer
density="compact"
>
<template #item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
>
{{ item.status }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="viewAsset(item.id)"
/>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAssetsStore } from '../stores/assets';
import { useUIStore } from '../stores/ui';
export default {
name: 'Dashboard',
setup() {
const router = useRouter();
const assetsStore = useAssetsStore();
const uiStore = useUIStore();
const assetHeaders = [
{ title: 'Name', value: 'name', sortable: false },
{ title: 'Identifier', value: 'asset_identifier', sortable: false },
{ title: 'Category', value: 'category', sortable: false },
{ title: 'Status', value: 'status', sortable: false },
{ title: 'Actions', value: 'actions', sortable: false, width: 80 },
];
// Mock data for demo
const mockAssets = [
{
id: '1',
name: 'Dell Laptop #001',
asset_identifier: 'IT001',
category: 'IT Equipment',
status: 'active',
},
{
id: '2',
name: 'Conference Table',
asset_identifier: 'FUR001',
category: 'Office Furniture',
status: 'active',
},
{
id: '3',
name: 'X-Ray Machine',
asset_identifier: 'MED001',
category: 'Medical Equipment',
status: 'maintenance',
},
];
const totalAssets = computed(() => 24); // Mock data
const activeAssets = computed(() => 20); // Mock data
const maintenanceAssets = computed(() => 3); // Mock data
const totalValue = computed(() => 125000); // Mock data
const recentAssets = computed(() => mockAssets);
const isLoading = computed(() => assetsStore.isLoading);
const createAsset = () => {
router.push('/assets/add');
};
const createWorkOrder = () => {
router.push('/work-orders/new');
};
const scanQRCode = () => {
uiStore.showInfo('QR Code scanner feature coming soon!');
};
const viewReports = () => {
router.push('/reports');
};
const viewAllAssets = () => {
router.push('/assets');
};
const viewAsset = (id) => {
router.push(`/assets/${id}`);
};
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const getStatusColor = (status) => {
const colors = {
active: 'success',
inactive: 'default',
maintenance: 'warning',
retired: 'error',
};
return colors[status] || 'default';
};
onMounted(() => {
uiStore.setPageTitle('Dashboard');
});
return {
assetHeaders,
totalAssets,
activeAssets,
maintenanceAssets,
totalValue,
recentAssets,
isLoading,
createAsset,
createWorkOrder,
scanQRCode,
viewReports,
viewAllAssets,
viewAsset,
formatCurrency,
getStatusColor,
};
},
};
</script>
<style scoped>
.dashboard {
padding: 24px;
}
.v-card {
transition: all 0.3s ease;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
@media (max-width: 768px) {
.dashboard {
padding: 16px;
}
}
</style>

View File

@ -0,0 +1,273 @@
<!-- frontend/src/views/EditAsset.vue -->
<template>
<div class="edit-asset-page">
<!-- Page Header -->
<div class="page-header fluent-entrance mb-8">
<div class="d-flex align-center mb-4">
<v-btn
icon
variant="text"
color="neutral-primary"
class="mr-3"
@click="$router.go(-1)"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<div>
<h1 class="text-title1 mb-1">Edit Asset</h1>
<p class="text-body1" style="color: #605E5C;">
Update asset information and details
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="d-flex justify-center align-center" style="min-height: 400px;">
<v-progress-circular indeterminate size="64" />
</div>
<!-- Form Container -->
<v-row v-else-if="asset">
<v-col cols="12" lg="8" xl="6">
<v-card class="form-card fluent-layer-1" elevation="0">
<v-card-text class="pa-8">
<AssetForm
mode="edit"
:initial-data="asset"
:is-submitting="isSubmitting"
@submit="handleFormSubmit"
@cancel="handleFormCancel"
/>
</v-card-text>
</v-card>
</v-col>
<!-- Asset Info Panel -->
<v-col cols="12" lg="4" xl="6">
<v-card class="info-panel fluent-layer-1" elevation="0">
<v-card-text class="pa-6">
<h3 class="text-title3 mb-4" style="color: #323130;">
<v-icon color="primary" class="mr-2">mdi-information</v-icon>
Asset Information
</h3>
<div class="info-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Current Status</h4>
<v-chip
:color="getStatusColor(asset.status)"
size="small"
variant="flat"
>
{{ asset.status }}
</v-chip>
</div>
<div class="info-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Created</h4>
<p class="text-caption1" style="color: #605E5C;">
{{ formatDate(asset.date_created) }}
</p>
</div>
<div class="info-section mb-4">
<h4 class="text-body1 font-weight-bold mb-2">Last Updated</h4>
<p class="text-caption1" style="color: #605E5C;">
{{ formatDate(asset.date_updated) }}
</p>
</div>
<div class="info-section">
<h4 class="text-body1 font-weight-bold mb-2">Asset ID</h4>
<p class="text-caption1" style="color: #605E5C; font-family: monospace;">
{{ asset.id }}
</p>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Error State -->
<div v-else class="d-flex justify-center align-center" style="min-height: 400px;">
<v-alert type="error" variant="tonal" class="ma-4">
<v-alert-title>Asset Not Found</v-alert-title>
The requested asset could not be loaded. It may have been deleted or you don't have permission to view it.
<template #append>
<v-btn variant="text" @click="$router.push('/assets')">
Back to Assets
</v-btn>
</template>
</v-alert>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useUIStore } from '../stores/ui';
import { useAssetsStore } from '../stores/assets';
import AssetForm from '../components/assets/AssetForm.vue';
export default {
name: 'EditAsset',
components: {
AssetForm,
},
setup() {
const router = useRouter();
const route = useRoute();
const uiStore = useUIStore();
const assetsStore = useAssetsStore();
const isLoading = ref(true);
const isSubmitting = ref(false);
// Use reactive asset from store (same as AssetDetail page)
const asset = computed(() => assetsStore.currentAsset);
// Load asset data
const loadAsset = async () => {
try {
const assetId = route.params.id;
console.log('📡 EditAsset: Loading asset data for ID:', assetId);
// Force fresh database fetch for editing - ignore cache/optimistic updates
const loadedAsset = await assetsStore.fetchAssetById(assetId, true);
console.log('✅ EditAsset: Asset loaded:', {
name: loadedAsset.name,
lastUpdated: loadedAsset.date_updated
});
// Update page title with asset name
uiStore.setPageTitle(`Edit ${loadedAsset.name}`);
} catch (error) {
console.error('Failed to load asset:', error);
uiStore.showError('Failed to load asset data');
} finally {
isLoading.value = false;
}
};
// Handle form submission
const handleFormSubmit = async (formData) => {
console.log('🚨 EditAsset handleFormSubmit called with:', {
assetId: route.params.id,
formData,
fieldCount: Object.keys(formData).length
});
isSubmitting.value = true;
try {
const assetId = route.params.id;
console.log('🚨 EditAsset calling assetsStore.updateAsset...');
// Update the asset using the store
await assetsStore.updateAsset(assetId, formData);
console.log('🚨 EditAsset updateAsset completed, navigating...');
uiStore.showSuccess('Asset updated successfully!');
router.push('/assets');
} catch (error) {
uiStore.showError('Failed to update asset. Please try again.');
console.error('Error updating asset:', error);
} finally {
isSubmitting.value = false;
}
};
// Handle form cancellation
const handleFormCancel = () => {
router.go(-1);
};
// Helper methods
const getStatusColor = (status) => {
const colors = {
active: 'success',
inactive: 'default',
maintenance: 'warning',
retired: 'error',
};
return colors[status] || 'default';
};
const formatDate = (date) => {
if (!date) return 'Unknown';
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date));
};
onMounted(async () => {
await loadAsset();
});
return {
isLoading,
isSubmitting,
asset,
handleFormSubmit,
handleFormCancel,
getStatusColor,
formatDate,
};
},
};
</script>
<style scoped>
.edit-asset-page {
padding: 24px;
animation: fluent-entrance 0.3s cubic-bezier(0.1, 0.9, 0.2, 1);
max-width: 1400px;
margin: 0 auto;
}
.form-card {
border: 1px solid #E1DFDD;
border-radius: 4px;
background: #FFFFFF;
transition: all 0.2s cubic-bezier(0.1, 0.9, 0.2, 1);
}
.info-panel {
border: 1px solid #E1DFDD;
border-radius: 4px;
background: #FFFFFF;
position: sticky;
top: 24px;
}
.info-section {
h4 {
color: #323130;
}
p {
line-height: 1.5;
}
}
@media (max-width: 768px) {
.edit-asset-page {
padding: 16px;
}
.form-card .v-card-text {
padding: 24px 16px;
}
.info-panel {
position: static;
margin-top: 24px;
}
}
</style>

View File

@ -0,0 +1,213 @@
<!-- frontend/src/views/Login.vue -->
<template>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center" class="fill-height">
<v-col cols="12" sm="8" md="6" lg="4" xl="3">
<v-card class="elevation-8 pa-6" rounded="lg">
<!-- Header -->
<v-card-title class="text-center pa-0 mb-6">
<div class="d-flex flex-column align-center">
<v-icon size="64" color="primary" class="mb-4">
mdi-office-building
</v-icon>
<h1 class="text-h4 font-weight-bold">Asset Management</h1>
<p class="text-subtitle-1 text-grey-darken-1 mt-2">
Sign in to your account
</p>
</div>
</v-card-title>
<!-- Login Form -->
<v-card-text class="pa-0">
<v-form @submit.prevent="handleLogin" ref="form">
<!-- Email field -->
<v-text-field
v-model="email"
label="Email"
type="email"
prepend-inner-icon="mdi-email"
:rules="emailRules"
required
variant="outlined"
class="mb-3"
:disabled="isLoading"
/>
<!-- Password field -->
<v-text-field
v-model="password"
label="Password"
:type="showPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
:rules="passwordRules"
required
variant="outlined"
class="mb-4"
:disabled="isLoading"
@click:append-inner="showPassword = !showPassword"
/>
<!-- Error alert -->
<v-alert
v-if="error"
type="error"
class="mb-4"
closable
@update:model-value="clearError"
>
{{ error }}
</v-alert>
<!-- Login button -->
<v-btn
type="submit"
color="primary"
block
size="large"
:loading="isLoading"
:disabled="!isFormValid"
class="mb-4"
>
Sign In
</v-btn>
<!-- Demo credentials info -->
<v-card variant="tonal" color="info" class="pa-3">
<v-card-title class="text-subtitle-2 pa-0 mb-2">
Demo Credentials
</v-card-title>
<v-card-text class="pa-0 text-caption">
<strong>Email:</strong> admin@assetmanagement.com<br />
<strong>Password:</strong> AssetAdmin2024!
</v-card-text>
</v-card>
</v-form>
</v-card-text>
</v-card>
<!-- Footer links -->
<div class="text-center mt-4">
<v-btn
variant="text"
size="small"
@click="showForgotPassword"
class="text-caption"
>
Forgot Password?
</v-btn>
<span class="mx-2 text-grey"></span>
<v-btn
variant="text"
size="small"
@click="showSignUp"
class="text-caption"
>
Sign Up
</v-btn>
</div>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useUIStore } from '../stores/ui';
export default {
name: 'Login',
setup() {
const router = useRouter();
const authStore = useAuthStore();
const uiStore = useUIStore();
const form = ref(null);
const email = ref('admin@assetmanagement.com'); // Pre-filled for demo
const password = ref('AssetAdmin2024!'); // Pre-filled for demo
const showPassword = ref(false);
const emailRules = [
(v) => !!v || 'Email is required',
(v) => /.+@.+\..+/.test(v) || 'Email must be valid',
];
const passwordRules = [
(v) => !!v || 'Password is required',
(v) => v.length >= 6 || 'Password must be at least 6 characters',
];
const isFormValid = computed(() => {
return (
email.value &&
password.value &&
/.+@.+\..+/.test(email.value) &&
password.value.length >= 6
);
});
const isLoading = computed(() => authStore.isLoading);
const error = computed(() => authStore.error);
const handleLogin = async () => {
// Validate form first
const { valid } = await form.value.validate();
if (!valid) return;
try {
await authStore.login(email.value, password.value);
uiStore.showSuccess('Login successful!');
router.push('/');
} catch (error) {
console.error('Login failed:', error);
// Error is already set in the auth store
}
};
const clearError = () => {
authStore.clearError();
};
const showForgotPassword = () => {
uiStore.showInfo('Password reset feature coming soon!');
};
const showSignUp = () => {
uiStore.showInfo('Contact your administrator for new accounts');
};
return {
form,
email,
password,
showPassword,
emailRules,
passwordRules,
isFormValid,
isLoading,
error,
handleLogin,
clearError,
showForgotPassword,
showSignUp,
};
},
};
</script>
<style scoped>
.fill-height {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.v-card {
backdrop-filter: blur(10px);
}
.v-container {
padding: 20px;
}
</style>

104
frontend/vite.config.js Normal file
View File

@ -0,0 +1,104 @@
// frontend/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
vuetify({
autoImport: true,
styles: {
configFile: 'src/assets/styles/settings.scss'
}
}),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'Enterprise Asset Management',
short_name: 'AssetManager',
description: 'Professional asset management and tracking system',
theme_color: '#1976D2',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url)),
'@views': fileURLToPath(new URL('./src/views', import.meta.url)),
'@stores': fileURLToPath(new URL('./src/stores', import.meta.url)),
'@utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
'@assets': fileURLToPath(new URL('./src/assets', import.meta.url))
}
},
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://localhost:8055',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-ui': ['vuetify'],
'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
'vendor-charts': ['chart.js', 'vue-chartjs'],
'vendor-qr': ['qrcode', 'vue-qrcode-reader'],
'vendor-pdf': ['vue-pdf-embed', 'jspdf']
}
}
}
},
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'vuetify',
'axios',
'dayjs',
'qrcode',
'chart.js',
'lodash-es'
]
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/assets/styles/variables.scss";`
}
}
}
})

24
node_api/.env.example Normal file
View File

@ -0,0 +1,24 @@
# Custom Node API Configuration
PORT=3001
NODE_ENV=development
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=directus
DB_USER=directus
DB_PASSWORD=directus
# Directus Integration
DIRECTUS_URL=http://localhost:8055
DIRECTUS_ADMIN_EMAIL=admin@assetmanagement.com
DIRECTUS_ADMIN_PASSWORD=AssetAdmin2024!
# JWT Configuration (should match Directus)
JWT_SECRET=your-jwt-secret-key-here
JWT_EXPIRES_IN=1h
# Security
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

32
node_api/Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# node_api/Dockerfile
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodeapi -u 1001
# Change ownership
RUN chown -R nodeapi:nodejs /app
USER nodeapi
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
# Start application
CMD ["node", "server.js"]

62
node_api/db/connection.js Normal file
View File

@ -0,0 +1,62 @@
// node_api/db/connection.js
const { Pool } = require('pg');
// Create a singleton pool instance
let pool = null;
const createPool = () => {
if (!pool) {
pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'directus',
user: process.env.DB_USER || 'directus',
password: process.env.DB_PASSWORD || 'directus',
max: 20, // Maximum number of connections in the pool
idleTimeoutMillis: 30000, // Close idle connections after 30 seconds
connectionTimeoutMillis: 2000, // Return error after 2 seconds if no connection available
});
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
process.exit(-1);
});
console.log('📊 Database connection pool created');
}
return pool;
};
const getPool = () => {
if (!pool) {
createPool();
}
return pool;
};
const query = async (text, params) => {
const client = await getPool().connect();
try {
const result = await client.query(text, params);
return result;
} finally {
client.release();
}
};
const closePool = async () => {
if (pool) {
await pool.end();
pool = null;
console.log('📊 Database connection pool closed');
}
};
module.exports = {
createPool,
getPool,
query,
closePool
};

View File

@ -0,0 +1,79 @@
// node_api/middleware/auth.js
const jwt = require('jsonwebtoken');
const axios = require('axios');
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
/**
* Middleware to validate Directus JWT tokens
*/
const authMiddleware = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
// Validate token with Directus
try {
const response = await axios.get(`${DIRECTUS_URL}/users/me`, {
headers: {
Authorization: `Bearer ${token}`
}
});
// Attach user info to request
req.user = response.data.data;
req.token = token;
console.log(`✅ Authenticated user: ${req.user.email} (${req.user.role?.name || 'Unknown Role'})`);
next();
} catch (directusError) {
console.error('Directus token validation failed:', directusError.response?.data);
return res.status(401).json({ error: 'Invalid or expired token' });
}
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication service error' });
}
};
/**
* Middleware to check user permissions
*/
const requirePermission = (action, collection = 'assets') => {
return async (req, res, next) => {
try {
// For now, allow all authenticated users
// TODO: Implement proper permission checking with Directus policies
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
// Check if user is admin (always allowed)
if (req.user.role?.admin_access) {
return next();
}
// TODO: Check specific permissions against Directus policies
// For now, allow all authenticated users
console.log(`🔐 Permission check: ${req.user.email}${action} on ${collection}`);
next();
} catch (error) {
console.error('Permission check error:', error);
return res.status(500).json({ error: 'Permission service error' });
}
};
};
module.exports = {
authMiddleware,
requirePermission
};

5272
node_api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
node_api/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "asset-management-node-api",
"version": "1.0.0",
"description": "Custom Node.js API for asset data operations",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"dotenv": "^16.3.1",
"express-rate-limit": "^6.8.1",
"compression": "^1.7.4",
"morgan": "^1.10.0",
"axios": "^1.6.0"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.6.2"
},
"engines": {
"node": ">=18.0.0"
}
}

456
node_api/routes/assets.js Normal file
View File

@ -0,0 +1,456 @@
// node_api/routes/assets.js
const express = require('express');
const { requirePermission } = require('../middleware/auth');
const { getPool } = require('../db/connection');
const router = express.Router();
// Get database pool
const pool = getPool();
/**
* Get all assets for the user's organization
*/
router.get('/', requirePermission('read'), async (req, res) => {
try {
const query = `
SELECT
a.id,
a.name,
a.description,
a.asset_identifier,
a.serial_number,
a.model_number,
a.manufacturer,
a.acquisition_date,
a.acquisition_cost,
a.status,
a.notes,
a.date_created,
a.date_updated,
a.created_by,
a.updated_by,
a.organization_id,
c.id as category_id,
c.name as category_name,
l.id as location_id,
l.name as location_name,
f.id as image_id,
f.filename_download as image_filename
FROM assets a
LEFT JOIN asset_categories c ON a.category_id = c.id
LEFT JOIN locations l ON a.location_id = l.id
LEFT JOIN directus_files f ON a.image_url = f.id
WHERE a.organization_id = $1::uuid
ORDER BY a.date_created DESC
`;
const result = await pool.query(query, [req.user.organization_id]);
// Transform the data to match frontend expectations
const assets = result.rows.map(row => ({
id: row.id,
name: row.name,
description: row.description,
asset_identifier: row.asset_identifier,
serial_number: row.serial_number,
model_number: row.model_number,
manufacturer: row.manufacturer,
acquisition_date: row.acquisition_date,
acquisition_cost: row.acquisition_cost,
status: row.status,
notes: row.notes,
date_created: row.date_created,
date_updated: row.date_updated,
created_by: row.created_by,
updated_by: row.updated_by,
organization_id: row.organization_id,
category_id: row.category_id ? {
id: row.category_id,
name: row.category_name
} : null,
location_id: row.location_id ? {
id: row.location_id,
name: row.location_name
} : null,
image_url: row.image_id ? {
id: row.image_id,
filename_download: row.image_filename
} : null
}));
console.log(`📊 Retrieved ${assets.length} assets for user ${req.user.email}`);
res.json({ data: assets });
} catch (error) {
console.error('Get assets error:', error);
res.status(500).json({ error: 'Failed to retrieve assets' });
}
});
/**
* Get a specific asset by ID
*/
router.get('/:id', requirePermission('read'), async (req, res) => {
try {
const { id } = req.params;
const query = `
SELECT
a.id,
a.name,
a.description,
a.asset_identifier,
a.serial_number,
a.model_number,
a.manufacturer,
a.acquisition_date,
a.acquisition_cost,
a.status,
a.notes,
a.date_created,
a.date_updated,
a.created_by,
a.updated_by,
a.organization_id,
c.id as category_id,
c.name as category_name,
l.id as location_id,
l.name as location_name,
f.id as image_id,
f.filename_download as image_filename
FROM assets a
LEFT JOIN asset_categories c ON a.category_id = c.id
LEFT JOIN locations l ON a.location_id = l.id
LEFT JOIN directus_files f ON a.image_url = f.id
WHERE a.id = $1::uuid AND a.organization_id = $2::uuid
`;
const result = await pool.query(query, [id, req.user.organization_id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Asset not found' });
}
const row = result.rows[0];
const asset = {
id: row.id,
name: row.name,
description: row.description,
asset_identifier: row.asset_identifier,
serial_number: row.serial_number,
model_number: row.model_number,
manufacturer: row.manufacturer,
acquisition_date: row.acquisition_date,
acquisition_cost: row.acquisition_cost,
status: row.status,
notes: row.notes,
date_created: row.date_created,
date_updated: row.date_updated,
created_by: row.created_by,
updated_by: row.updated_by,
organization_id: row.organization_id,
category_id: row.category_id ? {
id: row.category_id,
name: row.category_name
} : null,
location_id: row.location_id ? {
id: row.location_id,
name: row.location_name
} : null,
image_url: row.image_id ? {
id: row.image_id,
filename_download: row.image_filename
} : null
};
console.log(`🔍 Retrieved asset ${id} for user ${req.user.email}`);
res.json({ data: asset });
} catch (error) {
console.error('Get asset error:', error);
res.status(500).json({ error: 'Failed to retrieve asset' });
}
});
/**
* Update an asset
*/
router.patch('/:id', requirePermission('update'), async (req, res) => {
try {
const { id } = req.params;
const updateData = req.body;
// Build dynamic update query
const updateFields = [];
const values = [];
let paramCount = 1;
// Add updatable fields
const allowedFields = [
'name', 'description', 'asset_identifier', 'serial_number',
'model_number', 'manufacturer', 'acquisition_date', 'acquisition_cost',
'status', 'notes', 'category_id', 'location_id', 'image_url'
];
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
updateFields.push(`${field} = $${paramCount}`);
values.push(updateData[field]);
paramCount++;
}
}
if (updateFields.length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
// Add timestamp and user tracking
updateFields.push(`date_updated = NOW()`);
updateFields.push(`updated_by = $${paramCount}`);
values.push(req.user.id);
paramCount++;
// Add where conditions
values.push(id);
values.push(req.user.organization_id);
const updateQuery = `
UPDATE assets
SET ${updateFields.join(', ')}
WHERE id = $${paramCount}::uuid AND organization_id = $${paramCount + 1}::uuid
RETURNING id
`;
const updateResult = await pool.query(updateQuery, values);
if (updateResult.rows.length === 0) {
return res.status(404).json({ error: 'Asset not found or unauthorized' });
}
// Fetch the updated asset with relationships
const fetchQuery = `
SELECT
a.id,
a.name,
a.description,
a.asset_identifier,
a.serial_number,
a.model_number,
a.manufacturer,
a.acquisition_date,
a.acquisition_cost,
a.status,
a.notes,
a.date_created,
a.date_updated,
a.created_by,
a.updated_by,
a.organization_id,
c.id as category_id,
c.name as category_name,
l.id as location_id,
l.name as location_name,
f.id as image_id,
f.filename_download as image_filename
FROM assets a
LEFT JOIN asset_categories c ON a.category_id = c.id
LEFT JOIN locations l ON a.location_id = l.id
LEFT JOIN directus_files f ON a.image_url = f.id
WHERE a.id = $1::uuid AND a.organization_id = $2::uuid
`;
const result = await pool.query(fetchQuery, [id, req.user.organization_id]);
const row = result.rows[0];
const asset = {
id: row.id,
name: row.name,
description: row.description,
asset_identifier: row.asset_identifier,
serial_number: row.serial_number,
model_number: row.model_number,
manufacturer: row.manufacturer,
acquisition_date: row.acquisition_date,
acquisition_cost: row.acquisition_cost,
status: row.status,
notes: row.notes,
date_created: row.date_created,
date_updated: row.date_updated,
created_by: row.created_by,
updated_by: row.updated_by,
organization_id: row.organization_id,
category_id: row.category_id ? {
id: row.category_id,
name: row.category_name
} : null,
location_id: row.location_id ? {
id: row.location_id,
name: row.location_name
} : null,
image_url: row.image_id ? {
id: row.image_id,
filename_download: row.image_filename
} : null
};
console.log(`✏️ Updated asset ${id} for user ${req.user.email}`);
res.json({ data: asset });
} catch (error) {
console.error('Update asset error:', error);
console.error('Error details:', {
message: error.message,
code: error.code,
detail: error.detail,
constraint: error.constraint
});
res.status(500).json({
error: 'Failed to update asset',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
});
/**
* Create a new asset
*/
router.post('/', requirePermission('create'), async (req, res) => {
try {
const assetData = req.body;
const insertQuery = `
INSERT INTO assets (
name, description, asset_identifier, serial_number, model_number, manufacturer,
acquisition_date, acquisition_cost, status, notes,
category_id, location_id, image_url, organization_id, created_by, date_created
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW()
) RETURNING id
`;
const values = [
assetData.name,
assetData.description,
assetData.asset_identifier,
assetData.serial_number,
assetData.model_number,
assetData.manufacturer,
assetData.acquisition_date,
assetData.acquisition_cost,
assetData.status || 'active',
assetData.notes,
assetData.category_id,
assetData.location_id,
assetData.image_url,
req.user.organization_id,
req.user.id
];
const result = await pool.query(insertQuery, values);
const assetId = result.rows[0].id;
// Fetch the created asset with relationships
const fetchQuery = `
SELECT
a.id,
a.name,
a.description,
a.asset_identifier,
a.serial_number,
a.model_number,
a.manufacturer,
a.acquisition_date,
a.acquisition_cost,
a.status,
a.notes,
a.date_created,
a.date_updated,
a.created_by,
a.updated_by,
a.organization_id,
c.id as category_id,
c.name as category_name,
l.id as location_id,
l.name as location_name,
f.id as image_id,
f.filename_download as image_filename
FROM assets a
LEFT JOIN asset_categories c ON a.category_id = c.id
LEFT JOIN locations l ON a.location_id = l.id
LEFT JOIN directus_files f ON a.image_url = f.id
WHERE a.id = $1
`;
const fetchResult = await pool.query(fetchQuery, [assetId]);
const row = fetchResult.rows[0];
const asset = {
id: row.id,
name: row.name,
description: row.description,
asset_identifier: row.asset_identifier,
serial_number: row.serial_number,
model_number: row.model_number,
manufacturer: row.manufacturer,
acquisition_date: row.acquisition_date,
acquisition_cost: row.acquisition_cost,
status: row.status,
notes: row.notes,
date_created: row.date_created,
date_updated: row.date_updated,
created_by: row.created_by,
updated_by: row.updated_by,
organization_id: row.organization_id,
category_id: row.category_id ? {
id: row.category_id,
name: row.category_name
} : null,
location_id: row.location_id ? {
id: row.location_id,
name: row.location_name
} : null,
image_url: row.image_id ? {
id: row.image_id,
filename_download: row.image_filename
} : null
};
console.log(` Created asset ${assetId} for user ${req.user.email}`);
res.status(201).json({ data: asset });
} catch (error) {
console.error('Create asset error:', error);
res.status(500).json({ error: 'Failed to create asset' });
}
});
/**
* Delete an asset
*/
router.delete('/:id', requirePermission('delete'), async (req, res) => {
try {
const { id } = req.params;
const deleteQuery = `
DELETE FROM assets
WHERE id = $1::uuid AND organization_id = $2::uuid
RETURNING id
`;
const result = await pool.query(deleteQuery, [id, req.user.organization_id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Asset not found or unauthorized' });
}
console.log(`🗑️ Deleted asset ${id} for user ${req.user.email}`);
res.status(204).send();
} catch (error) {
console.error('Delete asset error:', error);
res.status(500).json({ error: 'Failed to delete asset' });
}
});
module.exports = router;

73
node_api/routes/health.js Normal file
View File

@ -0,0 +1,73 @@
// node_api/routes/health.js
const express = require('express');
const axios = require('axios');
const { getPool } = require('../db/connection');
const router = express.Router();
// Get database pool
const pool = getPool();
/**
* Health check endpoint
*/
router.get('/', async (req, res) => {
const healthcheck = {
uptime: process.uptime(),
message: 'OK',
timestamp: new Date().toISOString(),
services: {}
};
try {
// Check database connection
try {
const dbResult = await pool.query('SELECT NOW()');
healthcheck.services.database = {
status: 'healthy',
timestamp: dbResult.rows[0].now
};
} catch (dbError) {
healthcheck.services.database = {
status: 'unhealthy',
error: dbError.message
};
}
// Check Directus connection
try {
const directusResponse = await axios.get(`${process.env.DIRECTUS_URL}/server/health`, {
timeout: 5000
});
healthcheck.services.directus = {
status: 'healthy',
version: directusResponse.data.status || 'unknown'
};
} catch (directusError) {
healthcheck.services.directus = {
status: 'unhealthy',
error: directusError.message
};
}
// Determine overall health
const allServicesHealthy = Object.values(healthcheck.services)
.every(service => service.status === 'healthy');
if (allServicesHealthy) {
res.status(200).json(healthcheck);
} else {
res.status(503).json(healthcheck);
}
} catch (error) {
console.error('Health check error:', error);
res.status(503).json({
...healthcheck,
message: 'Service Unavailable',
error: error.message
});
}
});
module.exports = router;

79
node_api/server.js Normal file
View File

@ -0,0 +1,79 @@
// node_api/server.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const { authMiddleware } = require('./middleware/auth');
const assetsRoutes = require('./routes/assets');
const healthRoutes = require('./routes/health');
const app = express();
const PORT = process.env.PORT || 3001;
// Security middleware
app.use(helmet());
app.use(compression());
// CORS configuration
const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173').split(',');
app.use(cors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Rate limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
});
app.use('/api/', limiter);
// Logging
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Health check (no auth required)
app.use('/health', healthRoutes);
// Protected API routes
app.use('/api', authMiddleware);
app.use('/api/assets', assetsRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message
});
});
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.listen(PORT, () => {
console.log(`🚀 Asset Management API running on port ${PORT}`);
console.log(`📊 Environment: ${process.env.NODE_ENV}`);
console.log(`🔗 CORS Origin: ${process.env.CORS_ORIGIN}`);
});

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "enterprise-asset-management",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Directus Collection Setup Script
* This script creates collections for our custom database tables in Directus
*/
const axios = require('axios');
const DIRECTUS_URL = 'http://localhost:8055';
const ADMIN_EMAIL = 'admin@assetmanagement.com';
const ADMIN_PASSWORD = 'AssetAdmin2024!';
let authToken = null;
// Create axios instance
const api = axios.create({
baseURL: DIRECTUS_URL,
timeout: 10000,
});
// Add auth token to requests
api.interceptors.request.use((config) => {
if (authToken) {
config.headers.Authorization = `Bearer ${authToken}`;
}
return config;
});
async function authenticate() {
try {
console.log('🔑 Authenticating with Directus...');
const response = await api.post('/auth/login', {
email: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
});
authToken = response.data.data.access_token;
console.log('✅ Authentication successful');
return authToken;
} catch (error) {
console.error('❌ Authentication failed:', error.response?.data || error.message);
throw error;
}
}
async function createCollection(collection) {
try {
console.log(`📁 Creating collection: ${collection.collection}`);
// Check if collection already exists
try {
await api.get(`/collections/${collection.collection}`);
console.log(` Collection ${collection.collection} already exists, skipping...`);
return;
} catch (error) {
// Collection doesn't exist, create it
}
await api.post('/collections', collection);
console.log(`✅ Collection ${collection.collection} created successfully`);
} catch (error) {
console.error(`❌ Failed to create collection ${collection.collection}:`,
error.response?.data || error.message);
}
}
async function setupCollections() {
console.log('🚀 Setting up Directus collections...\n');
// Define our collections based on the database schema
const collections = [
{
collection: 'organizations',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'organizations',
color: '#6644FF',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'business',
item_duplication_fields: null,
note: 'SaaS tenant organizations',
preview_url: null,
singleton: false,
sort: 1,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'subscription_plans',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'subscription_plans',
color: '#00C897',
display_template: '{{ display_name }}',
group: null,
hidden: false,
icon: 'paid',
item_duplication_fields: null,
note: 'Available subscription plans',
preview_url: null,
singleton: false,
sort: 2,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'asset_categories',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'asset_categories',
color: '#FF6B35',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'category',
item_duplication_fields: null,
note: 'Asset categorization',
preview_url: null,
singleton: false,
sort: 3,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'locations',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'locations',
color: '#4CAF50',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'place',
item_duplication_fields: null,
note: 'Asset locations and facilities',
preview_url: null,
singleton: false,
sort: 4,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'vendors',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'vendors',
color: '#9C27B0',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'store',
item_duplication_fields: null,
note: 'Vendors and suppliers',
preview_url: null,
singleton: false,
sort: 5,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'assets',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'assets',
color: '#0078D4',
display_template: '{{ name }} ({{ asset_identifier }})',
group: null,
hidden: false,
icon: 'inventory',
item_duplication_fields: null,
note: 'Main asset registry',
preview_url: null,
singleton: false,
sort: 6,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'asset_components',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'asset_components',
color: '#FF9800',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'construction',
item_duplication_fields: null,
note: 'Asset components and parts',
preview_url: null,
singleton: false,
sort: 7,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'asset_qr_codes',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'asset_qr_codes',
color: '#795548',
display_template: 'QR Code for {{ asset_id }}',
group: null,
hidden: false,
icon: 'qr_code',
item_duplication_fields: null,
note: 'QR codes for assets',
preview_url: null,
singleton: false,
sort: 8,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'work_order_types',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'work_order_types',
color: '#E91E63',
display_template: '{{ name }}',
group: null,
hidden: false,
icon: 'assignment',
item_duplication_fields: null,
note: 'Work order categories',
preview_url: null,
singleton: false,
sort: 9,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'work_orders',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'work_orders',
color: '#2196F3',
display_template: '{{ title }} ({{ work_order_number }})',
group: null,
hidden: false,
icon: 'build',
item_duplication_fields: null,
note: 'Work orders and maintenance requests',
preview_url: null,
singleton: false,
sort: 10,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
},
{
collection: 'asset_reminders',
meta: {
accountability: 'all',
archive_app_filter: true,
archive_field: null,
archive_value: null,
collapse: 'open',
collection: 'asset_reminders',
color: '#FF5722',
display_template: '{{ title }}',
group: null,
hidden: false,
icon: 'notifications',
item_duplication_fields: null,
note: 'Maintenance reminders and alerts',
preview_url: null,
singleton: false,
sort: 11,
sort_field: null,
translations: null,
unarchive_value: null,
versioning: false
},
schema: null
}
];
try {
await authenticate();
console.log('\n📁 Creating collections...\n');
for (const collection of collections) {
await createCollection(collection);
}
console.log('\n🎉 Collection setup completed successfully!');
console.log('\n📋 Next steps:');
console.log('1. Visit http://localhost:8055/admin to configure field permissions');
console.log('2. Test the frontend connection');
console.log('3. Create some test assets\n');
} catch (error) {
console.error('\n❌ Setup failed:', error.message);
process.exit(1);
}
}
// Run the setup
setupCollections().catch(console.error);

View File

@ -0,0 +1,103 @@
#!/bin/bash
# Directus Collection Setup Script
# This script creates collections for our custom database tables in Directus
DIRECTUS_URL="http://localhost:8055"
ADMIN_EMAIL="admin@assetmanagement.com"
ADMIN_PASSWORD="AssetAdmin2024!"
echo "🚀 Setting up Directus collections..."
echo
# Authenticate and get token
echo "🔑 Authenticating with Directus..."
AUTH_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}")
# Extract access token
ACCESS_TOKEN=$(echo $AUTH_RESPONSE | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ACCESS_TOKEN" ]; then
echo "❌ Authentication failed"
echo "Response: $AUTH_RESPONSE"
exit 1
fi
echo "✅ Authentication successful"
echo
# Function to create a collection
create_collection() {
local collection_name=$1
local display_name=$2
local icon=$3
local color=$4
local note=$5
echo "📁 Creating collection: $collection_name"
# Check if collection exists
EXISTING=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/collections/$collection_name" | grep -c "collection")
if [ "$EXISTING" -gt 0 ]; then
echo " Collection $collection_name already exists, skipping..."
return
fi
# Create collection
RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/collections" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"collection\": \"$collection_name\",
\"meta\": {
\"accountability\": \"all\",
\"archive_app_filter\": true,
\"collapse\": \"open\",
\"collection\": \"$collection_name\",
\"color\": \"$color\",
\"display_template\": null,
\"hidden\": false,
\"icon\": \"$icon\",
\"note\": \"$note\",
\"singleton\": false,
\"sort\": null,
\"versioning\": false
}
}")
if echo "$RESPONSE" | grep -q "error"; then
echo "⚠️ Warning: $RESPONSE"
else
echo "✅ Collection $collection_name created successfully"
fi
}
echo "📁 Creating collections..."
echo
# Create all collections
create_collection "organizations" "Organizations" "business" "#6644FF" "SaaS tenant organizations"
create_collection "subscription_plans" "Subscription Plans" "paid" "#00C897" "Available subscription plans"
create_collection "asset_categories" "Asset Categories" "category" "#FF6B35" "Asset categorization"
create_collection "locations" "Locations" "place" "#4CAF50" "Asset locations and facilities"
create_collection "vendors" "Vendors" "store" "#9C27B0" "Vendors and suppliers"
create_collection "assets" "Assets" "inventory" "#0078D4" "Main asset registry"
create_collection "asset_components" "Asset Components" "construction" "#FF9800" "Asset components and parts"
create_collection "asset_qr_codes" "Asset QR Codes" "qr_code" "#795548" "QR codes for assets"
create_collection "work_order_types" "Work Order Types" "assignment" "#E91E63" "Work order categories"
create_collection "work_orders" "Work Orders" "build" "#2196F3" "Work orders and maintenance requests"
create_collection "asset_reminders" "Asset Reminders" "notifications" "#FF5722" "Maintenance reminders and alerts"
echo
echo "🎉 Collection setup completed successfully!"
echo
echo "📋 Next steps:"
echo "1. Visit http://localhost:8055/admin to configure field permissions"
echo "2. Set up relationships between collections"
echo "3. Test the frontend connection"
echo "4. Create some test assets"
echo

View File

@ -0,0 +1,269 @@
#!/bin/bash
# Enhanced Directus Permissions Setup Script
# This script grants permissions and verifies they work correctly
DIRECTUS_URL="http://localhost:8055"
ADMIN_EMAIL="admin@assetmanagement.com"
ADMIN_PASSWORD="AssetAdmin2024!"
echo "🔐 Enhanced Directus Permissions Setup..."
echo
# Function to wait for Directus to be ready
wait_for_directus() {
echo "⏳ Waiting for Directus to be ready..."
while ! curl -s "$DIRECTUS_URL/server/health" > /dev/null; do
echo " Waiting for Directus..."
sleep 2
done
echo "✅ Directus is ready"
}
# Function to authenticate and get token
authenticate() {
echo "🔑 Authenticating with Directus..."
local max_attempts=3
local attempt=1
while [ $attempt -le $max_attempts ]; do
AUTH_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}")
ACCESS_TOKEN=$(echo $AUTH_RESPONSE | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
if [ -n "$ACCESS_TOKEN" ]; then
echo "✅ Authentication successful (attempt $attempt)"
return 0
else
echo "❌ Authentication failed (attempt $attempt/$max_attempts)"
echo "Response: $AUTH_RESPONSE"
attempt=$((attempt + 1))
sleep 2
fi
done
echo "❌ Authentication failed after $max_attempts attempts"
exit 1
}
# Function to get admin role ID
get_admin_role() {
echo "👤 Getting admin role ID..."
ROLES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/roles")
ADMIN_ROLE_ID=$(echo $ROLES_RESPONSE | grep -o '"id":"[^"]*","name":"Administrator"' | cut -d'"' -f4)
if [ -z "$ADMIN_ROLE_ID" ]; then
echo "❌ Could not find Administrator role"
echo "Available roles:"
echo $ROLES_RESPONSE | grep -o '"name":"[^"]*"' | cut -d'"' -f4
exit 1
fi
echo "✅ Found admin role: $ADMIN_ROLE_ID"
}
# Function to check if collection exists
collection_exists() {
local collection=$1
COLLECTION_CHECK=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/collections/$collection")
if echo "$COLLECTION_CHECK" | grep -q '"collection"'; then
return 0
else
return 1
fi
}
# Function to create permission with validation
create_permission() {
local collection=$1
local action=$2
# Check if collection exists first
if ! collection_exists "$collection"; then
echo " ⚠️ Collection '$collection' does not exist, skipping..."
return 1
fi
echo " 📋 Granting $action permission for $collection..."
# Check if permission already exists
EXISTING_PERMISSION=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/permissions?filter[role][_eq]=$ADMIN_ROLE_ID&filter[collection][_eq]=$collection&filter[action][_eq]=$action")
if echo "$EXISTING_PERMISSION" | grep -q '"id"'; then
echo " Permission already exists"
return 0
fi
PERMISSION_DATA="{
\"role\": \"$ADMIN_ROLE_ID\",
\"collection\": \"$collection\",
\"action\": \"$action\",
\"permissions\": {},
\"validation\": {},
\"presets\": null,
\"fields\": [\"*\"]
}"
RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/permissions" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$PERMISSION_DATA")
if echo "$RESPONSE" | grep -q '"id"'; then
echo " ✅ Permission created successfully"
return 0
else
echo " ❌ Failed to create permission: $(echo $RESPONSE | grep -o '"message":"[^"]*"' | cut -d'"' -f4)"
return 1
fi
}
# Function to verify permissions work
verify_permissions() {
local collection=$1
echo " 🔍 Verifying $collection permissions..."
# Test read permission
READ_TEST=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/$collection?limit=1")
if echo "$READ_TEST" | grep -q '"data"'; then
echo " ✅ Read permission verified"
else
echo " ❌ Read permission failed: $(echo $READ_TEST | grep -o '"message":"[^"]*"' | cut -d'"' -f4)"
return 1
fi
return 0
}
# Function to invalidate cache
invalidate_cache() {
echo "🗑️ Invalidating Directus cache..."
CACHE_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/utils/cache/clear" \
-H "Authorization: Bearer $ACCESS_TOKEN")
if echo "$CACHE_RESPONSE" | grep -q -v "error"; then
echo "✅ Cache cleared successfully"
else
echo "⚠️ Cache clear may have failed, but continuing..."
fi
}
# Main execution
wait_for_directus
authenticate
get_admin_role
# Collections that need permissions
COLLECTIONS=(
"organizations"
"subscription_plans"
"asset_categories"
"locations"
"vendors"
"assets"
"asset_components"
"asset_qr_codes"
"work_order_types"
"work_orders"
"asset_reminders"
)
echo
echo "🔓 Creating and verifying permissions for all collections..."
SUCCESS_COUNT=0
TOTAL_COUNT=0
for collection in "${COLLECTIONS[@]}"; do
echo "📁 Processing collection: $collection"
COLLECTION_SUCCESS=0
for action in "create" "read" "update" "delete"; do
if create_permission "$collection" "$action"; then
COLLECTION_SUCCESS=$((COLLECTION_SUCCESS + 1))
fi
TOTAL_COUNT=$((TOTAL_COUNT + 1))
done
# Verify permissions for this collection
if [ $COLLECTION_SUCCESS -gt 0 ]; then
if verify_permissions "$collection"; then
SUCCESS_COUNT=$((SUCCESS_COUNT + COLLECTION_SUCCESS))
fi
fi
echo
done
# Clear cache after permission changes
invalidate_cache
echo "📊 Permission Setup Summary:"
echo " ✅ Successful: $SUCCESS_COUNT/$TOTAL_COUNT permissions"
echo " 📁 Collections processed: ${#COLLECTIONS[@]}"
if [ $SUCCESS_COUNT -gt 0 ]; then
echo
echo "🧪 Running comprehensive test..."
# Test asset creation
echo "📋 Testing asset creation..."
# Get required IDs
ORG_ID=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/organizations?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
CAT_ID=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/asset_categories?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
LOC_ID=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/locations?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$ORG_ID" ] && [ -n "$CAT_ID" ] && [ -n "$LOC_ID" ]; then
TEST_ASSET_DATA="{
\"organization_id\": \"$ORG_ID\",
\"name\": \"Permission Test Asset\",
\"asset_identifier\": \"PERM-TEST-$(date +%s)\",
\"category_id\": \"$CAT_ID\",
\"location_id\": \"$LOC_ID\",
\"acquisition_cost\": 999.99
}"
CREATE_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/items/assets" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$TEST_ASSET_DATA")
if echo "$CREATE_RESPONSE" | grep -q '"id"'; then
echo "✅ Asset creation test successful"
echo "🎯 Frontend should work correctly now"
else
echo "❌ Asset creation test failed:"
echo "$CREATE_RESPONSE" | head -c 200
fi
else
echo "⚠️ Missing required data for asset test (org: $ORG_ID, cat: $CAT_ID, loc: $LOC_ID)"
fi
echo
echo "🎉 Enhanced permission setup completed!"
echo
echo "📋 Next steps:"
echo "1. Try your frontend application"
echo "2. Check Directus admin: http://localhost:8055/admin"
echo "3. Monitor console logs for any permission issues"
echo "4. Run ./scripts/verify-permissions.sh anytime to check status"
else
echo "❌ Permission setup failed. Check Directus logs and configuration."
exit 1
fi

131
scripts/setup-permissions.sh Executable file
View File

@ -0,0 +1,131 @@
#!/bin/bash
# Setup Directus Permissions Script
# This script grants the admin role full permissions to our custom collections
DIRECTUS_URL="http://localhost:8055"
ADMIN_EMAIL="admin@assetmanagement.com"
ADMIN_PASSWORD="AssetAdmin2024!"
echo "🔐 Setting up Directus permissions..."
echo
# Authenticate and get token
echo "🔑 Authenticating with Directus..."
AUTH_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}")
ACCESS_TOKEN=$(echo $AUTH_RESPONSE | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ACCESS_TOKEN" ]; then
echo "❌ Authentication failed"
exit 1
fi
echo "✅ Authentication successful"
# Get the admin role ID
echo "👤 Getting admin role ID..."
ROLES_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/roles")
ADMIN_ROLE_ID=$(echo $ROLES_RESPONSE | grep -o '"id":"[^"]*","name":"Administrator"' | head -1 | cut -d'"' -f4)
if [ -z "$ADMIN_ROLE_ID" ]; then
echo "❌ Could not find Administrator role"
exit 1
fi
echo "✅ Found admin role: $ADMIN_ROLE_ID"
# Collections that need permissions
COLLECTIONS=(
"organizations"
"subscription_plans"
"asset_categories"
"locations"
"vendors"
"assets"
"asset_components"
"asset_qr_codes"
"work_order_types"
"work_orders"
"asset_reminders"
)
# Function to create permission for a collection
create_permission() {
local collection=$1
local action=$2
echo " 📋 Granting $action permission for $collection..."
PERMISSION_DATA="{
\"role\": \"$ADMIN_ROLE_ID\",
\"collection\": \"$collection\",
\"action\": \"$action\",
\"permissions\": {},
\"validation\": {},
\"presets\": null,
\"fields\": [\"*\"]
}"
RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/permissions" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$PERMISSION_DATA")
if echo "$RESPONSE" | grep -q "error"; then
echo " ⚠️ Warning: $(echo $RESPONSE | grep -o '"message":"[^"]*"' | cut -d'"' -f4)"
fi
}
echo
echo "🔓 Creating permissions for all collections..."
# Create CRUD permissions for each collection
for collection in "${COLLECTIONS[@]}"; do
echo "📁 Setting up permissions for: $collection"
create_permission "$collection" "create"
create_permission "$collection" "read"
create_permission "$collection" "update"
create_permission "$collection" "delete"
echo
done
echo "🎉 Permission setup completed!"
echo
echo "📋 Testing asset creation..."
# Test creating an asset with required fields
TEST_ASSET_DATA='{
"organization_id": "'$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/organizations?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)'",
"name": "Test Asset",
"asset_identifier": "TEST-'$(date +%s)'",
"category_id": "'$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/asset_categories?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)'",
"location_id": "'$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$DIRECTUS_URL/items/locations?limit=1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)'",
"acquisition_cost": 1000.00
}'
echo "Testing with data: $TEST_ASSET_DATA"
echo
CREATE_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/items/assets" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d "$TEST_ASSET_DATA")
if echo "$CREATE_RESPONSE" | grep -q "error"; then
echo "❌ Asset creation still failing:"
echo "$CREATE_RESPONSE"
else
echo "✅ Test asset created successfully!"
echo "🎯 Frontend should now be able to create assets"
fi
echo
echo "📋 Next steps:"
echo "1. Try creating an asset through your frontend again"
echo "2. Check the Directus admin panel to verify permissions"
echo "3. Visit http://localhost:8055/admin/settings/roles/permissions"

View File

@ -0,0 +1,86 @@
#!/bin/bash
# Test Frontend-Backend Connection Script
# This script verifies that the frontend can connect to Directus and perform operations
DIRECTUS_URL="http://localhost:8055"
ADMIN_EMAIL="admin@assetmanagement.com"
ADMIN_PASSWORD="AssetAdmin2024!"
echo "🔗 Testing Frontend-Backend Connection..."
echo
# Test 1: Authentication
echo "1⃣ Testing authentication..."
AUTH_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}")
ACCESS_TOKEN=$(echo $AUTH_RESPONSE | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ACCESS_TOKEN" ]; then
echo "❌ Authentication failed"
exit 1
fi
echo "✅ Authentication successful"
# Test 2: Check collections are accessible
echo
echo "2⃣ Testing collection access..."
COLLECTIONS_RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/collections")
if echo "$COLLECTIONS_RESPONSE" | grep -q "assets"; then
echo "✅ Assets collection accessible"
else
echo "⚠️ Assets collection not found"
fi
# Test 3: Check if sample data exists
echo
echo "3⃣ Checking for sample data..."
# Check organizations
ORG_COUNT=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/organizations" | grep -o '"id"' | wc -l)
echo "📊 Organizations: $ORG_COUNT"
# Check asset categories
CAT_COUNT=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/asset_categories" | grep -o '"id"' | wc -l)
echo "📊 Asset Categories: $CAT_COUNT"
# Check locations
LOC_COUNT=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/locations" | grep -o '"id"' | wc -l)
echo "📊 Locations: $LOC_COUNT"
# Check vendors
VENDOR_COUNT=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/vendors" | grep -o '"id"' | wc -l)
echo "📊 Vendors: $VENDOR_COUNT"
# Check existing assets
ASSET_COUNT=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/assets" | grep -o '"id"' | wc -l)
echo "📊 Assets: $ASSET_COUNT"
echo
if [ "$ORG_COUNT" -gt 0 ] && [ "$CAT_COUNT" -gt 0 ] && [ "$LOC_COUNT" -gt 0 ]; then
echo "✅ Sample data exists - ready for frontend testing"
echo
echo "🎯 Frontend URLs to test:"
echo "- Frontend: http://localhost:5173 (or check your frontend port)"
echo "- Directus Admin: http://localhost:8055/admin"
echo "- Login: $ADMIN_EMAIL / $ADMIN_PASSWORD"
else
echo "⚠️ Missing sample data - this might cause frontend issues"
echo "Consider running the database reset: make db-reset"
fi
echo
echo "📋 Next steps:"
echo "1. Open your frontend application"
echo "2. Try logging in with the admin credentials"
echo "3. Test creating a new asset"
echo "4. Verify data appears in Directus admin panel"

59
scripts/verify-permissions.sh Executable file
View File

@ -0,0 +1,59 @@
#!/bin/bash
# Permission Verification Script
# Quickly check if all permissions are working correctly
DIRECTUS_URL="http://localhost:8055"
ADMIN_EMAIL="admin@assetmanagement.com"
ADMIN_PASSWORD="AssetAdmin2024!"
echo "🔍 Verifying Directus Permissions..."
echo
# Authenticate
AUTH_RESPONSE=$(curl -s -X POST "$DIRECTUS_URL/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}")
ACCESS_TOKEN=$(echo $AUTH_RESPONSE | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
if [ -z "$ACCESS_TOKEN" ]; then
echo "❌ Authentication failed"
exit 1
fi
echo "✅ Authentication successful"
# Test collections
COLLECTIONS=("organizations" "asset_categories" "locations" "vendors" "assets")
echo "📊 Testing collection access..."
SUCCESS=0
TOTAL=0
for collection in "${COLLECTIONS[@]}"; do
TOTAL=$((TOTAL + 1))
RESPONSE=$(curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"$DIRECTUS_URL/items/$collection?limit=1")
if echo "$RESPONSE" | grep -q '"data"'; then
echo "$collection - OK"
SUCCESS=$((SUCCESS + 1))
else
echo "$collection - FAILED"
echo " $(echo $RESPONSE | grep -o '"message":"[^"]*"' | cut -d'"' -f4)"
fi
done
echo
echo "📈 Results: $SUCCESS/$TOTAL collections accessible"
if [ $SUCCESS -eq $TOTAL ]; then
echo "🎉 All permissions working correctly!"
exit 0
else
echo "⚠️ Some permissions are missing. Run setup-permissions-enhanced.sh"
exit 1
fi