Initial commit: Enterprise Asset Management System - Imageupload, Add and Edit Assets, ShadcnUI
This commit is contained in:
commit
2271d6ab00
|
|
@ -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/
|
||||
|
|
@ -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!
|
||||
|
|
@ -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! 🎯
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "enterprise-asset-management",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue