621 lines
20 KiB
Vue
621 lines
20 KiB
Vue
<!-- 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> |