stash
This commit is contained in:
parent
7c91661be2
commit
796fef81be
37 changed files with 3404 additions and 108 deletions
480
frontend/src/components/workflow/steps/BulkImportStep1.vue
Normal file
480
frontend/src/components/workflow/steps/BulkImportStep1.vue
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
<template>
|
||||
<div class="bulk-import-step-1">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">File Upload</h4>
|
||||
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Area -->
|
||||
<div class="upload-section mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div v-if="!uploadedFile" class="upload-dropzone text-center py-5"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
:class="{ 'dragover': isDragOver }"
|
||||
@dragenter="isDragOver = true"
|
||||
@dragleave="isDragOver = false">
|
||||
|
||||
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
|
||||
<h5 class="mb-3">Upload Your Data File</h5>
|
||||
<p class="text-muted mb-4">
|
||||
Drag and drop your CSV or Excel file here, or click to browse
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
@change="handleFileSelect"
|
||||
class="d-none"
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
|
||||
<b-icon-folder-open class="me-2"></b-icon-folder-open>
|
||||
Choose File
|
||||
</button>
|
||||
<button class="btn btn-outline-info" @click="downloadTemplate">
|
||||
<b-icon-download class="me-2"></b-icon-download>
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="supported-formats">
|
||||
<small class="text-muted">
|
||||
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Info Display -->
|
||||
<div v-else class="file-info">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<b-icon-file-earmark-spreadsheet class="text-success me-3" style="font-size: 2rem;"></b-icon-file-earmark-spreadsheet>
|
||||
<div>
|
||||
<h6 class="mb-1">{{ uploadedFile.name }}</h6>
|
||||
<small class="text-muted">
|
||||
{{ formatFileSize(uploadedFile.size) }} •
|
||||
{{ getFileType(uploadedFile.name) }} •
|
||||
Uploaded {{ formatDateTime(uploadTime) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" @click="removeFile">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File Analysis Results -->
|
||||
<div v-if="fileAnalysis" class="file-analysis">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary mb-1">{{ fileAnalysis.totalRows }}</h4>
|
||||
<small class="text-muted">Total Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info mb-1">{{ fileAnalysis.totalColumns }}</h4>
|
||||
<small class="text-muted">Columns</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success mb-1">{{ fileAnalysis.validRows }}</h4>
|
||||
<small class="text-muted">Valid Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning mb-1">{{ fileAnalysis.errorRows }}</h4>
|
||||
<small class="text-muted">Issues Found</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Processing Status -->
|
||||
<div v-if="processing" class="processing-status mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<div>
|
||||
<h6 class="mb-1">Processing file...</h6>
|
||||
<p class="text-muted mb-0">{{ processingStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Mapping -->
|
||||
<div v-if="fileAnalysis && !processing" class="column-mapping mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Column Mapping</h6>
|
||||
<small class="text-muted">Map your file columns to system fields</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="field in requiredFields" :key="field.key" class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select" v-model="columnMapping[field.key]">
|
||||
<option value="">Select column...</option>
|
||||
<option v-for="column in detectedColumns" :key="column" :value="column">
|
||||
{{ column }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preview -->
|
||||
<div v-if="previewData.length > 0" class="data-preview mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Data Preview</h6>
|
||||
<small class="text-muted">First 5 rows of your data</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in detectedColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in previewData.slice(0, 5)" :key="index">
|
||||
<td v-for="column in detectedColumns" :key="column">
|
||||
{{ row[column] || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedToNext"
|
||||
:disabled="!canProceed"
|
||||
>
|
||||
Next: Parse Data
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'BulkImportStep1',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
uploadedFile: null,
|
||||
uploadTime: null,
|
||||
processing: false,
|
||||
processingStatus: '',
|
||||
isDragOver: false,
|
||||
fileAnalysis: null,
|
||||
detectedColumns: [],
|
||||
previewData: [],
|
||||
columnMapping: {},
|
||||
requiredFields: [
|
||||
{ key: 'name', label: 'Item Name', required: true },
|
||||
{ key: 'category', label: 'Category', required: false },
|
||||
{ key: 'quantity', label: 'Quantity', required: false },
|
||||
{ key: 'unit', label: 'Unit', required: false },
|
||||
{ key: 'description', label: 'Description', required: false },
|
||||
{ key: 'location', label: 'Storage Location', required: false },
|
||||
{ key: 'purchase_price', label: 'Purchase Price', required: false },
|
||||
{ key: 'estimated_value', label: 'Estimated Value', required: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canProceed() {
|
||||
return this.uploadedFile && this.fileAnalysis && this.columnMapping.name;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing data if resuming
|
||||
if (this.payload.uploaded_file) {
|
||||
this.uploadedFile = this.payload.uploaded_file;
|
||||
this.uploadTime = this.payload.upload_time;
|
||||
this.fileAnalysis = this.payload.file_analysis;
|
||||
this.detectedColumns = this.payload.detected_columns || [];
|
||||
this.previewData = this.payload.preview_data || [];
|
||||
this.columnMapping = this.payload.column_mapping || {};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleFileDrop(event) {
|
||||
this.isDragOver = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
async processFile(file) {
|
||||
if (!this.isValidFileType(file)) {
|
||||
alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadedFile = file;
|
||||
this.uploadTime = new Date().toISOString();
|
||||
this.processing = true;
|
||||
this.processingStatus = 'Reading file...';
|
||||
|
||||
try {
|
||||
// Simulate file processing
|
||||
await this.analyzeFile(file);
|
||||
this.processingStatus = 'Analyzing data structure...';
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.processingStatus = 'Generating preview...';
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.updatePayload();
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error);
|
||||
alert('Error processing file. Please try again.');
|
||||
this.removeFile();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
this.processingStatus = '';
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeFile(file) {
|
||||
// This is a simplified version - in reality, you'd use a library like Papa Parse for CSV
|
||||
// or SheetJS for Excel files
|
||||
|
||||
if (file.name.endsWith('.csv')) {
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length > 0) {
|
||||
// Parse CSV header
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
this.detectedColumns = headers;
|
||||
|
||||
// Parse preview data
|
||||
this.previewData = lines.slice(1, 6).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
this.fileAnalysis = {
|
||||
totalRows: lines.length - 1, // Exclude header
|
||||
totalColumns: headers.length,
|
||||
validRows: lines.length - 1, // Simplified - assume all valid for demo
|
||||
errorRows: 0
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For Excel files, you'd use a library like SheetJS
|
||||
// This is a mock implementation
|
||||
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
||||
this.previewData = [
|
||||
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
||||
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
|
||||
];
|
||||
this.fileAnalysis = {
|
||||
totalRows: 100,
|
||||
totalColumns: 5,
|
||||
validRows: 98,
|
||||
errorRows: 2
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-map columns based on common names
|
||||
this.autoMapColumns();
|
||||
},
|
||||
|
||||
autoMapColumns() {
|
||||
const mapping = {};
|
||||
|
||||
this.detectedColumns.forEach(column => {
|
||||
const lowerColumn = column.toLowerCase();
|
||||
|
||||
if (lowerColumn.includes('name') || lowerColumn.includes('item')) {
|
||||
mapping.name = column;
|
||||
} else if (lowerColumn.includes('category') || lowerColumn.includes('type')) {
|
||||
mapping.category = column;
|
||||
} else if (lowerColumn.includes('quantity') || lowerColumn.includes('qty')) {
|
||||
mapping.quantity = column;
|
||||
} else if (lowerColumn.includes('unit')) {
|
||||
mapping.unit = column;
|
||||
} else if (lowerColumn.includes('description') || lowerColumn.includes('desc')) {
|
||||
mapping.description = column;
|
||||
} else if (lowerColumn.includes('location') || lowerColumn.includes('storage')) {
|
||||
mapping.location = column;
|
||||
} else if (lowerColumn.includes('price') && lowerColumn.includes('purchase')) {
|
||||
mapping.purchase_price = column;
|
||||
} else if (lowerColumn.includes('value') || lowerColumn.includes('price')) {
|
||||
mapping.estimated_value = column;
|
||||
}
|
||||
});
|
||||
|
||||
this.columnMapping = mapping;
|
||||
},
|
||||
|
||||
isValidFileType(file) {
|
||||
const validTypes = ['.csv', '.xlsx', '.xls'];
|
||||
return validTypes.some(type => file.name.toLowerCase().endsWith(type));
|
||||
},
|
||||
|
||||
getFileType(filename) {
|
||||
if (filename.endsWith('.csv')) return 'CSV';
|
||||
if (filename.endsWith('.xlsx') || filename.endsWith('.xls')) return 'Excel';
|
||||
return 'Unknown';
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
formatDateTime(dateString) {
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
removeFile() {
|
||||
this.uploadedFile = null;
|
||||
this.uploadTime = null;
|
||||
this.fileAnalysis = null;
|
||||
this.detectedColumns = [];
|
||||
this.previewData = [];
|
||||
this.columnMapping = {};
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
downloadTemplate() {
|
||||
// Create a sample CSV template
|
||||
const headers = ['Name', 'Category', 'Quantity', 'Unit', 'Description', 'Location', 'Purchase Price', 'Estimated Value'];
|
||||
const sampleData = [
|
||||
['Hammer', 'Tools', '1', 'piece', 'Claw hammer for general use', 'Toolbox A', '25.99', '30.00'],
|
||||
['Screws', 'Hardware', '100', 'pack', 'Wood screws 2 inch', 'Storage Bin 3', '12.50', '15.00']
|
||||
];
|
||||
|
||||
const csvContent = [headers, ...sampleData]
|
||||
.map(row => row.map(field => `"${field}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'inventory_import_template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
uploaded_file: this.uploadedFile,
|
||||
upload_time: this.uploadTime,
|
||||
file_analysis: this.fileAnalysis,
|
||||
detected_columns: this.detectedColumns,
|
||||
preview_data: this.previewData,
|
||||
column_mapping: this.columnMapping
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
if (!this.canProceed) {
|
||||
alert('Please upload a file and map the required Name column before proceeding.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-dropzone {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover,
|
||||
.upload-dropzone.dragover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-analysis {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
274
frontend/src/components/workflow/steps/FotoFirstStep1.vue
Normal file
274
frontend/src/components/workflow/steps/FotoFirstStep1.vue
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<template>
|
||||
<div class="foto-first-step-1">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Photo Capture</h4>
|
||||
<p class="text-muted">Capture or upload item photos to begin the import process.</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-area mb-4">
|
||||
<div class="row">
|
||||
<!-- Camera Capture -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera>
|
||||
<h6>Camera Capture</h6>
|
||||
<p class="text-muted small">Use your device camera to capture photos</p>
|
||||
<button class="btn btn-primary" @click="startCamera" :disabled="loading">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Start Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
|
||||
<h6>File Upload</h6>
|
||||
<p class="text-muted small">Upload photos from your device</p>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
multiple
|
||||
accept="image/*"
|
||||
@change="handleFileUpload"
|
||||
class="d-none"
|
||||
/>
|
||||
<button class="btn btn-success" @click="$refs.fileInput.click()" :disabled="loading">
|
||||
<b-icon-upload class="me-1"></b-icon-upload>
|
||||
Upload Photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Preview -->
|
||||
<div v-if="showCamera" class="camera-section mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Camera Preview</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-x></b-icon-x>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="camera-container text-center">
|
||||
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
|
||||
<div>
|
||||
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Capture Photo
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-stop class="me-1"></b-icon-stop>
|
||||
Stop Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Captured Photos ({{ photos.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="clearAllPhotos">
|
||||
<b-icon-trash class="me-1"></b-icon-trash>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Photo {{ index + 1 }}</small>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="removePhoto(index)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedToNext"
|
||||
:disabled="photos.length === 0 || loading"
|
||||
>
|
||||
Next: Process Images
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep1',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
showCamera: false,
|
||||
cameraReady: false,
|
||||
photos: [],
|
||||
stream: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing photos from payload
|
||||
if (this.payload.photos) {
|
||||
this.photos = [...this.payload.photos];
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopCamera();
|
||||
},
|
||||
methods: {
|
||||
async startCamera() {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
this.$refs.video.srcObject = this.stream;
|
||||
this.showCamera = true;
|
||||
this.cameraReady = true;
|
||||
} catch (error) {
|
||||
console.error('Error accessing camera:', error);
|
||||
alert('Could not access camera. Please check permissions or use file upload instead.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.showCamera = false;
|
||||
this.cameraReady = false;
|
||||
},
|
||||
|
||||
capturePhoto() {
|
||||
if (!this.cameraReady) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const video = this.$refs.video;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
const photo = {
|
||||
file: blob,
|
||||
preview: URL.createObjectURL(blob),
|
||||
name: `camera-photo-${Date.now()}.jpg`,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.photos.push(photo);
|
||||
this.updatePayload();
|
||||
}, 'image/jpeg', 0.8);
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const photo = {
|
||||
file: file,
|
||||
preview: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.photos.push(photo);
|
||||
}
|
||||
});
|
||||
this.updatePayload();
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
removePhoto(index) {
|
||||
URL.revokeObjectURL(this.photos[index].preview);
|
||||
this.photos.splice(index, 1);
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
|
||||
this.photos = [];
|
||||
this.updatePayload();
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', { photos: this.photos });
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.photo-thumbnail {
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-area .card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.upload-area .card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
360
frontend/src/components/workflow/steps/FotoFirstStep2.vue
Normal file
360
frontend/src/components/workflow/steps/FotoFirstStep2.vue
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<template>
|
||||
<div class="foto-first-step-2">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Image Processing</h4>
|
||||
<p class="text-muted">Processing and optimizing your captured images...</p>
|
||||
</div>
|
||||
|
||||
<!-- Processing Status -->
|
||||
<div class="processing-status mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Processing Progress</h6>
|
||||
<span class="badge bg-primary">{{ processedCount }}/{{ totalPhotos }}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress mb-3" style="height: 12px;">
|
||||
<div
|
||||
class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
:style="{ width: progressPercentage + '%' }"
|
||||
:class="{ 'bg-success': isComplete, 'bg-primary': !isComplete }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="processing-details">
|
||||
<div v-if="currentlyProcessing" class="d-flex align-items-center text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span>Processing: {{ currentlyProcessing }}</span>
|
||||
</div>
|
||||
<div v-else-if="isComplete" class="d-flex align-items-center text-success">
|
||||
<b-icon-check-circle class="me-2"></b-icon-check-circle>
|
||||
<span>All images processed successfully!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Options -->
|
||||
<div class="processing-options mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Processing Options</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="autoRotate"
|
||||
v-model="processingOptions.auto_rotate"
|
||||
:disabled="processing"
|
||||
>
|
||||
<label class="form-check-label" for="autoRotate">
|
||||
Auto-rotate images based on EXIF data
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="compress"
|
||||
v-model="processingOptions.compress"
|
||||
:disabled="processing"
|
||||
>
|
||||
<label class="form-check-label" for="compress">
|
||||
Compress images for optimal storage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="processingOptions.max_width"
|
||||
:disabled="processing"
|
||||
min="480"
|
||||
max="4096"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="processingOptions.max_height"
|
||||
:disabled="processing"
|
||||
min="480"
|
||||
max="4096"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processed Images Preview -->
|
||||
<div v-if="processedImages.length > 0" class="processed-images mb-4">
|
||||
<h6 class="mb-3">Processed Images</h6>
|
||||
<div class="row">
|
||||
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<img :src="image.processedUrl" class="card-img-top processed-thumbnail" :alt="`Processed ${index + 1}`">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">{{ image.name }}</small>
|
||||
<span class="badge bg-success">
|
||||
<b-icon-check></b-icon-check>
|
||||
</span>
|
||||
</div>
|
||||
<div class="processing-info">
|
||||
<small class="text-muted d-block">
|
||||
{{ formatFileSize(image.originalSize) }} → {{ formatFileSize(image.processedSize) }}
|
||||
</small>
|
||||
<small class="text-success">
|
||||
{{ Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) }}% reduced
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')" :disabled="processing">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
v-if="!processing && !isComplete"
|
||||
class="btn btn-primary"
|
||||
@click="startProcessing"
|
||||
>
|
||||
<b-icon-gear class="me-1"></b-icon-gear>
|
||||
Start Processing
|
||||
</button>
|
||||
<button
|
||||
v-if="isComplete"
|
||||
class="btn btn-success"
|
||||
@click="proceedToNext"
|
||||
>
|
||||
Next: Enter Item Details
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep2',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
processedCount: 0,
|
||||
currentlyProcessing: null,
|
||||
processedImages: [],
|
||||
processingOptions: {
|
||||
auto_rotate: true,
|
||||
compress: true,
|
||||
max_width: 1920,
|
||||
max_height: 1080
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalPhotos() {
|
||||
return this.payload.photos?.length || 0;
|
||||
},
|
||||
progressPercentage() {
|
||||
return this.totalPhotos > 0 ? (this.processedCount / this.totalPhotos) * 100 : 0;
|
||||
},
|
||||
isComplete() {
|
||||
return this.processedCount === this.totalPhotos && this.totalPhotos > 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load processing options from payload
|
||||
if (this.payload.processing_options) {
|
||||
this.processingOptions = { ...this.processingOptions, ...this.payload.processing_options };
|
||||
}
|
||||
|
||||
// Load processed images if they exist
|
||||
if (this.payload.processed_images) {
|
||||
this.processedImages = [...this.payload.processed_images];
|
||||
this.processedCount = this.processedImages.length;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async startProcessing() {
|
||||
if (!this.payload.photos || this.payload.photos.length === 0) {
|
||||
alert('No photos to process. Please go back and add photos first.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.processedCount = 0;
|
||||
this.processedImages = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < this.payload.photos.length; i++) {
|
||||
const photo = this.payload.photos[i];
|
||||
this.currentlyProcessing = photo.name;
|
||||
|
||||
const processedImage = await this.processImage(photo);
|
||||
this.processedImages.push(processedImage);
|
||||
this.processedCount++;
|
||||
|
||||
// Small delay to show progress
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
this.currentlyProcessing = null;
|
||||
this.updatePayload();
|
||||
} catch (error) {
|
||||
console.error('Error processing images:', error);
|
||||
alert('Error processing images. Please try again.');
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async processImage(photo) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Calculate new dimensions
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
this.processingOptions.max_width,
|
||||
this.processingOptions.max_height
|
||||
);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
const processedImage = {
|
||||
name: photo.name,
|
||||
originalSize: photo.file.size,
|
||||
processedSize: blob.size,
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedFile: blob,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
resolve(processedImage);
|
||||
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
|
||||
};
|
||||
img.src = photo.preview;
|
||||
});
|
||||
},
|
||||
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
|
||||
// Scale down if needed
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height;
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
return { width: Math.round(width), height: Math.round(height) };
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
processing_options: this.processingOptions,
|
||||
processed_images: this.processedImages
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
// Clean up object URLs
|
||||
this.processedImages.forEach(image => {
|
||||
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image.processedUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.processed-thumbnail {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.processing-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar-animated {
|
||||
animation: progress-bar-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-stripes {
|
||||
0% {
|
||||
background-position: 1rem 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
419
frontend/src/components/workflow/steps/FotoFirstStep3.vue
Normal file
419
frontend/src/components/workflow/steps/FotoFirstStep3.vue
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<template>
|
||||
<div class="foto-first-step-3">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Item Details Entry</h4>
|
||||
<p class="text-muted">Enter details for each photographed item to complete the inventory import.</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="progress-indicator mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Item Progress</h6>
|
||||
<span class="badge bg-info">{{ currentItemIndex + 1 }} of {{ totalItems }}</span>
|
||||
</div>
|
||||
<div class="progress mb-2" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar bg-info"
|
||||
:style="{ width: itemProgressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Item Display -->
|
||||
<div v-if="currentItem" class="current-item mb-4">
|
||||
<div class="row">
|
||||
<!-- Image Preview -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" alt="Current item">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">{{ currentItem.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Details Form -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Item Details</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="saveCurrentItem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Item Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="currentItemDetails.name"
|
||||
required
|
||||
placeholder="Enter item name"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" v-model="currentItemDetails.category">
|
||||
<option value="">Select category...</option>
|
||||
<option value="tools">Tools</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="hardware">Hardware</option>
|
||||
<option value="materials">Materials</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.quantity"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Unit</label>
|
||||
<select class="form-select" v-model="currentItemDetails.unit">
|
||||
<option value="piece">Piece</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="box">Box</option>
|
||||
<option value="pack">Pack</option>
|
||||
<option value="meter">Meter</option>
|
||||
<option value="kilogram">Kilogram</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Condition</label>
|
||||
<select class="form-select" v-model="currentItemDetails.condition">
|
||||
<option value="new">New</option>
|
||||
<option value="excellent">Excellent</option>
|
||||
<option value="good">Good</option>
|
||||
<option value="fair">Fair</option>
|
||||
<option value="poor">Poor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
rows="3"
|
||||
v-model="currentItemDetails.description"
|
||||
placeholder="Optional description or notes"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Storage Location</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="currentItemDetails.location"
|
||||
placeholder="e.g., Shelf A, Drawer 3, etc."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Purchase Price</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.purchase_price"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Estimated Value</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.estimated_value"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Navigation -->
|
||||
<div class="item-navigation mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="previousItem"
|
||||
:disabled="currentItemIndex === 0"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous Item
|
||||
</button>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" @click="saveCurrentItem">
|
||||
<b-icon-check class="me-1"></b-icon-check>
|
||||
Save Item
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" @click="skipCurrentItem">
|
||||
<b-icon-skip-forward class="me-1"></b-icon-skip-forward>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="nextItem"
|
||||
:disabled="currentItemIndex >= totalItems - 1"
|
||||
>
|
||||
Next Item
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Items Summary -->
|
||||
<div v-if="completedItems.length > 0" class="completed-items mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Completed Items ({{ completedItems.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-info" @click="showCompleted = !showCompleted">
|
||||
<b-icon-eye v-if="!showCompleted"></b-icon-eye>
|
||||
<b-icon-eye-slash v-else></b-icon-eye-slash>
|
||||
{{ showCompleted ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showCompleted" class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="completed-item-thumb me-2" alt="Item">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold small">{{ item.details.name }}</div>
|
||||
<div class="text-muted small">{{ item.details.category || 'No category' }}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="editItem(index)">
|
||||
<b-icon-pencil></b-icon-pencil>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
@click="proceedToNext"
|
||||
:disabled="completedItems.length === 0"
|
||||
>
|
||||
Next: Complete Import ({{ completedItems.length }} items)
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep3',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentItemIndex: 0,
|
||||
currentItemDetails: this.getDefaultItemDetails(),
|
||||
completedItems: [],
|
||||
showCompleted: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
availableItems() {
|
||||
return this.payload.processed_images || this.payload.photos || [];
|
||||
},
|
||||
totalItems() {
|
||||
return this.availableItems.length;
|
||||
},
|
||||
currentItem() {
|
||||
return this.availableItems[this.currentItemIndex] || null;
|
||||
},
|
||||
itemProgressPercentage() {
|
||||
return this.totalItems > 0 ? (this.completedItems.length / this.totalItems) * 100 : 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing completed items
|
||||
if (this.payload.completed_items) {
|
||||
this.completedItems = [...this.payload.completed_items];
|
||||
}
|
||||
|
||||
// Load current item details if resuming
|
||||
if (this.payload.current_item_details) {
|
||||
this.currentItemDetails = { ...this.payload.current_item_details };
|
||||
}
|
||||
|
||||
// Load current item index if resuming
|
||||
if (this.payload.current_item_index !== undefined) {
|
||||
this.currentItemIndex = this.payload.current_item_index;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDefaultItemDetails() {
|
||||
return {
|
||||
name: '',
|
||||
category: '',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
condition: 'good',
|
||||
description: '',
|
||||
location: '',
|
||||
purchase_price: null,
|
||||
estimated_value: null
|
||||
};
|
||||
},
|
||||
|
||||
saveCurrentItem() {
|
||||
if (!this.currentItemDetails.name.trim()) {
|
||||
alert('Please enter an item name before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
image: this.currentItem,
|
||||
details: { ...this.currentItemDetails },
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Check if we're editing an existing item
|
||||
const existingIndex = this.completedItems.findIndex(item =>
|
||||
item.image === this.currentItem
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.completedItems.splice(existingIndex, 1, itemData);
|
||||
} else {
|
||||
this.completedItems.push(itemData);
|
||||
}
|
||||
|
||||
this.nextItem();
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
skipCurrentItem() {
|
||||
this.nextItem();
|
||||
},
|
||||
|
||||
nextItem() {
|
||||
if (this.currentItemIndex < this.totalItems - 1) {
|
||||
this.currentItemIndex++;
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
},
|
||||
|
||||
previousItem() {
|
||||
if (this.currentItemIndex > 0) {
|
||||
this.currentItemIndex--;
|
||||
|
||||
// Load details if this item was already completed
|
||||
const existingItem = this.completedItems.find(item =>
|
||||
item.image === this.currentItem
|
||||
);
|
||||
|
||||
if (existingItem) {
|
||||
this.currentItemDetails = { ...existingItem.details };
|
||||
} else {
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
editItem(index) {
|
||||
const item = this.completedItems[index];
|
||||
// Find the item index in available items
|
||||
const itemIndex = this.availableItems.findIndex(img => img === item.image);
|
||||
if (itemIndex >= 0) {
|
||||
this.currentItemIndex = itemIndex;
|
||||
this.currentItemDetails = { ...item.details };
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
completed_items: this.completedItems,
|
||||
current_item_details: this.currentItemDetails,
|
||||
current_item_index: this.currentItemIndex
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
if (this.completedItems.length === 0) {
|
||||
alert('Please complete at least one item before proceeding.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-image {
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.completed-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item-navigation {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
376
frontend/src/components/workflow/steps/FotoFirstStep4.vue
Normal file
376
frontend/src/components/workflow/steps/FotoFirstStep4.vue
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<template>
|
||||
<div class="foto-first-step-4">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Import Completion</h4>
|
||||
<p class="text-muted">Review and finalize your imported items.</p>
|
||||
</div>
|
||||
|
||||
<!-- Import Summary -->
|
||||
<div class="import-summary mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-primary mb-2">{{ totalItems }}</h3>
|
||||
<p class="card-text text-muted mb-0">Items Imported</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-success mb-2">{{ categorizedItems }}</h3>
|
||||
<p class="card-text text-muted mb-0">With Categories</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-info mb-2">{{ itemsWithLocation }}</h3>
|
||||
<p class="card-text text-muted mb-0">With Locations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-warning mb-2">${{ totalValue }}</h3>
|
||||
<p class="card-text text-muted mb-0">Total Value</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<div class="category-breakdown mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Items by Category</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
|
||||
<div v-for="(count, category) in categoryBreakdown" :key="category" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
|
||||
<span class="badge bg-secondary">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted text-center py-3">
|
||||
No items to categorize
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Options -->
|
||||
<div class="import-options mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Import Options</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="generateQr"
|
||||
v-model="importOptions.generate_qr_codes"
|
||||
>
|
||||
<label class="form-check-label" for="generateQr">
|
||||
Generate QR codes for items
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="sendNotification"
|
||||
v-model="importOptions.send_notification"
|
||||
>
|
||||
<label class="form-check-label" for="sendNotification">
|
||||
Send completion notification
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="createReport"
|
||||
v-model="importOptions.create_report"
|
||||
>
|
||||
<label class="form-check-label" for="createReport">
|
||||
Create import report
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="autoBackup"
|
||||
v-model="importOptions.auto_backup"
|
||||
>
|
||||
<label class="form-check-label" for="autoBackup">
|
||||
Auto-backup imported data
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item List -->
|
||||
<div class="item-list mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Imported Items</h6>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
class="btn"
|
||||
:class="viewMode === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:class="viewMode === 'list' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Grid View -->
|
||||
<div v-if="viewMode === 'grid'" class="row">
|
||||
<div v-for="(item, index) in items" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card h-100">
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="card-img-top item-thumb" :alt="item.details.name">
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title mb-1">{{ item.details.name }}</h6>
|
||||
<p class="card-text small text-muted mb-1">{{ item.details.category || 'No category' }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Qty: {{ item.details.quantity }}</small>
|
||||
<small v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Quantity</th>
|
||||
<th>Location</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in items" :key="index">
|
||||
<td>
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="list-item-thumb" :alt="item.details.name">
|
||||
</td>
|
||||
<td class="fw-bold">{{ item.details.name }}</td>
|
||||
<td>
|
||||
<span v-if="item.details.category" class="badge bg-light text-dark">{{ item.details.category }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>{{ item.details.quantity }} {{ item.details.unit }}</td>
|
||||
<td>{{ item.details.location || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Action -->
|
||||
<div class="final-action text-center">
|
||||
<div class="card">
|
||||
<div class="card-body py-4">
|
||||
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
|
||||
<h5 class="mb-3">Ready to Complete Import</h5>
|
||||
<p class="text-muted mb-4">
|
||||
All {{ totalItems }} items have been processed and are ready to be added to your inventory.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
@click="completeImport"
|
||||
:disabled="importing"
|
||||
>
|
||||
<div v-if="importing" class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<b-icon-check-circle v-else class="me-2"></b-icon-check-circle>
|
||||
{{ importing ? 'Importing...' : 'Complete Import' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep4',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
importing: false,
|
||||
viewMode: 'grid',
|
||||
importOptions: {
|
||||
generate_qr_codes: true,
|
||||
send_notification: true,
|
||||
create_report: true,
|
||||
auto_backup: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return this.payload.completed_items || [];
|
||||
},
|
||||
totalItems() {
|
||||
return this.items.length;
|
||||
},
|
||||
categorizedItems() {
|
||||
return this.items.filter(item => item.details.category).length;
|
||||
},
|
||||
itemsWithLocation() {
|
||||
return this.items.filter(item => item.details.location).length;
|
||||
},
|
||||
totalValue() {
|
||||
return this.items.reduce((sum, item) => {
|
||||
return sum + (item.details.estimated_value || 0);
|
||||
}, 0).toFixed(2);
|
||||
},
|
||||
categoryBreakdown() {
|
||||
const breakdown = {};
|
||||
this.items.forEach(item => {
|
||||
const category = item.details.category || 'uncategorized';
|
||||
breakdown[category] = (breakdown[category] || 0) + 1;
|
||||
});
|
||||
return breakdown;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load import options from payload
|
||||
if (this.payload.import_options) {
|
||||
this.importOptions = { ...this.importOptions, ...this.payload.import_options };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async completeImport() {
|
||||
if (this.totalItems === 0) {
|
||||
alert('No items to import. Please go back and add items.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to import ${this.totalItems} items? This action cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
this.importing = true;
|
||||
|
||||
// Update payload with final options
|
||||
this.updatePayload();
|
||||
|
||||
// Simulate import process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Complete the workflow
|
||||
this.$emit('update', {
|
||||
import_completed: true,
|
||||
completion_timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Navigate to success or trigger workflow completion
|
||||
this.$emit('complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error completing import:', error);
|
||||
alert('Error completing import. Please try again.');
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
import_options: this.importOptions,
|
||||
final_summary: {
|
||||
total_items: this.totalItems,
|
||||
categorized_items: this.categorizedItems,
|
||||
items_with_location: this.itemsWithLocation,
|
||||
total_value: parseFloat(this.totalValue),
|
||||
category_breakdown: this.categoryBreakdown
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.list-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.final-action .card {
|
||||
border: 2px solid #28a745;
|
||||
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue