This commit is contained in:
j3d1 2026-08-01 22:20:45 +02:00
parent cfcc2c15d3
commit 4f2fe011c0
28 changed files with 3499 additions and 2922 deletions

View file

@ -1,112 +0,0 @@
/**
* Workflow Step Component Registry
*
* This module handles the dynamic loading and registration of workflow step components.
* It provides a centralized way to map workflow types and steps to their corresponding Vue components.
*/
// Import all step components
import FotoFirstStep1 from './steps/FotoFirstStep1.vue';
import FotoFirstStep2 from './steps/FotoFirstStep2.vue';
import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
import BulkImportStep1 from './steps/BulkImportStep1.vue';
import BulkImportStep6 from './steps/BulkImportStep6.vue';
/**
* Component registry mapping workflow types and steps to components
* Format: 'workflowType-step' -> Component
*/
const componentRegistry = {
// Foto First Import Workflow components
'foto-first-bulk-import-1': FotoFirstStep1,
'foto-first-bulk-import-2': FotoFirstStep2,
'foto-first-bulk-import-3': FotoFirstStep3,
'foto-first-bulk-import-4': FotoFirstStep4,
// Bulk Item Import Workflow components
'import-items-1': BulkImportStep1,
'import-items-6': BulkImportStep6,
// Additional steps can be added as needed
// 'import-items-2': BulkImportStep2,
// 'import-items-3': BulkImportStep3,
// ... etc
};
/**
* Get a step component for a given workflow type and step
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @returns {Object|null} Vue component or null if not found
*/
export function getStepComponent(workflowType, step) {
const componentKey = `${workflowType}-${step}`;
return componentRegistry[componentKey] || null;
}
/**
* Register a new step component
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @param {Object} component - The Vue component
*/
export function registerStepComponent(workflowType, step, component) {
const componentKey = `${workflowType}-${step}`;
componentRegistry[componentKey] = component;
}
/**
* Get all registered components for a workflow type
* @param {string} workflowType - The workflow type identifier
* @returns {Object} Object with step numbers as keys and components as values
*/
export function getWorkflowComponents(workflowType) {
const workflowComponents = {};
Object.keys(componentRegistry).forEach(key => {
if (key.startsWith(`${workflowType}-`)) {
const step = key.replace(`${workflowType}-`, '');
workflowComponents[step] = componentRegistry[key];
}
});
return workflowComponents;
}
/**
* Check if a step component exists for a workflow type and step
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @returns {boolean} True if component exists, false otherwise
*/
export function hasStepComponent(workflowType, step) {
const componentKey = `${workflowType}-${step}`;
return componentKey in componentRegistry;
}
/**
* Get all registered workflow types
* @returns {Array<string>} Array of workflow type identifiers
*/
export function getRegisteredWorkflowTypes() {
const workflowTypes = new Set();
Object.keys(componentRegistry).forEach(key => {
const parts = key.split('-');
if (parts.length >= 2) {
// Reconstruct workflow type (everything except the last part which is the step)
const workflowType = parts.slice(0, -1).join('-');
workflowTypes.add(workflowType);
}
});
return Array.from(workflowTypes);
}
export default {
getStepComponent,
registerStepComponent,
getWorkflowComponents,
hasStepComponent,
getRegisteredWorkflowTypes
};

View file

@ -1,193 +0,0 @@
/**
* Workflow Step Component Example Usage
*
* This file demonstrates how to use the workflow step component dispatching system
* and provides examples for developers who want to create new workflow steps.
*/
import {
getStepComponent,
registerStepComponent,
hasStepComponent,
getWorkflowComponents,
getRegisteredWorkflowTypes
} from './ComponentRegistry.js';
/**
* Example: Creating and registering a new workflow step component
*/
// 1. Create your step component (example)
const ExampleWorkflowStep1 = {
name: 'ExampleWorkflowStep1',
props: {
workflowInstance: { type: Object, required: true },
step: { type: String, required: true },
payload: { type: Object, default: () => ({}) }
},
template: `
<div class="example-step">
<h4>Example Workflow - Step 1</h4>
<p>This is a custom workflow step component.</p>
<button @click="$emit('next')" class="btn btn-primary">
Next Step
</button>
</div>
`
};
// 2. Register the component
registerStepComponent('example-workflow', '1', ExampleWorkflowStep1);
/**
* Example: Using the component registry programmatically
*/
export function demonstrateComponentRegistry() {
console.log('=== Workflow Component Registry Demo ===');
// Check if a component exists
console.log('Has foto-first step 1:', hasStepComponent('foto-first-bulk-import', '1'));
console.log('Has non-existent step:', hasStepComponent('non-existent', '999'));
// Get a specific component
const step1Component = getStepComponent('foto-first-bulk-import', '1');
console.log('Retrieved component:', step1Component?.name);
// Get all components for a workflow
const fotoFirstComponents = getWorkflowComponents('foto-first-bulk-import');
console.log('Foto First components:', Object.keys(fotoFirstComponents));
// Get all registered workflow types
const workflowTypes = getRegisteredWorkflowTypes();
console.log('Registered workflow types:', workflowTypes);
return {
availableWorkflows: workflowTypes,
fotoFirstSteps: Object.keys(fotoFirstComponents),
totalComponents: workflowTypes.reduce((total, type) => {
return total + Object.keys(getWorkflowComponents(type)).length;
}, 0)
};
}
/**
* Example: Dynamic component loading in a Vue component
*/
export const WorkflowStepLoader = {
name: 'WorkflowStepLoader',
props: {
workflowType: { type: String, required: true },
currentStep: { type: [String, Number], required: true },
workflowInstance: { type: Object, required: true },
payload: { type: Object, default: () => ({}) }
},
computed: {
stepComponent() {
return getStepComponent(this.workflowType, this.currentStep);
},
hasStepComponent() {
return hasStepComponent(this.workflowType, this.currentStep);
}
},
template: `
<div class="workflow-step-loader">
<!-- Custom step component if available -->
<component
v-if="stepComponent"
:is="stepComponent"
:workflow-instance="workflowInstance"
:step="currentStep.toString()"
:payload="payload"
@update="$emit('update', $event)"
@next="$emit('next')"
@prev="$emit('prev')"
@complete="$emit('complete')"
/>
<!-- Fallback content if no custom component -->
<div v-else class="default-step-content">
<h5>{{ workflowType }} - Step {{ currentStep }}</h5>
<p class="text-muted">No custom component found for this step.</p>
<div class="d-flex justify-content-between">
<button @click="$emit('prev')" class="btn btn-outline-secondary">
Previous
</button>
<button @click="$emit('next')" class="btn btn-primary">
Next
</button>
</div>
</div>
</div>
`
};
/**
* Development utilities for workflow components
*/
export const WorkflowDevUtils = {
/**
* List all available workflow steps
*/
listAllSteps() {
const workflowTypes = getRegisteredWorkflowTypes();
const allSteps = {};
workflowTypes.forEach(type => {
allSteps[type] = Object.keys(getWorkflowComponents(type));
});
return allSteps;
},
/**
* Validate workflow step coverage
*/
validateWorkflowCoverage(workflowDefinitions) {
const results = {};
Object.entries(workflowDefinitions).forEach(([type, definition]) => {
const requiredSteps = definition.getStepDefinitions().map(s => s.step.toString());
const availableSteps = Object.keys(getWorkflowComponents(type));
results[type] = {
required: requiredSteps,
available: availableSteps,
missing: requiredSteps.filter(step => !availableSteps.includes(step)),
coverage: (availableSteps.length / requiredSteps.length) * 100
};
});
return results;
},
/**
* Generate component registry report
*/
generateReport() {
const workflowTypes = getRegisteredWorkflowTypes();
const report = {
totalWorkflowTypes: workflowTypes.length,
totalComponents: 0,
workflows: {}
};
workflowTypes.forEach(type => {
const components = getWorkflowComponents(type);
const stepCount = Object.keys(components).length;
report.totalComponents += stepCount;
report.workflows[type] = {
steps: stepCount,
stepNumbers: Object.keys(components).sort((a, b) => parseInt(a) - parseInt(b))
};
});
return report;
}
};
export default {
demonstrateComponentRegistry,
WorkflowStepLoader,
WorkflowDevUtils
};

View file

@ -1,147 +1,123 @@
# Workflow Step Component Dispatching System
# Workflow System
## Overview
This system enables dynamic dispatching of Vue.js components based on workflow type and current step in the WorkflowDetail view. It provides a flexible, extensible architecture for creating custom step-specific user interfaces for different workflow types.
Each workflow type (e.g. `foto-first-bulk-import`, `import-items`) is
implemented by **one** Vue file that contains **all of its steps** and
**its own metadata** (name, category, description, icons, duration, step
list, initial payload). There is no separate per-step component registry and
no parallel class hierarchy for metadata - a single flat catalog in
`@/workflows.js` ties everything together.
## Architecture
### 1. Component Registry (`/components/workflow/ComponentRegistry.js`)
### 1. Workflow components (`/components/workflow/workflows/*.vue`)
The central registry that maps workflow types and steps to their corresponding Vue components using the format: `workflowType-step` → Component.
Each file is fully self-contained:
**Key Functions:**
- `getStepComponent(workflowType, step)` - Retrieves a step component
- `registerStepComponent(workflowType, step, component)` - Registers new components
- `hasStepComponent(workflowType, step)` - Checks component existence
- `getWorkflowComponents(workflowType)` - Gets all components for a workflow
- `getRegisteredWorkflowTypes()` - Lists all registered workflow types
- **Metadata**: a static `meta: { id, name, category, description, icons,
estimatedDuration, stepDefinitions, getInitialPayload() }` option on the
component's options object, right alongside `name`/`components`/`props`.
- **UI**: the same component implements every step internally, branching on
the `step` prop (e.g. `v-if="step === '1'"`, `v-else-if="step === '2'"`, ...).
### 2. Step Components (`/components/workflow/steps/`)
Currently implemented:
- **FotoFirstBulkImportWorkflow.vue** - all 4 steps of `foto-first-bulk-import`:
photo capture (camera + upload), image processing/compression, per-item
detail entry, and import completion.
- **BulkItemImportWorkflow.vue** - `import-items`' file upload (step 1) and
item creation (step 6) with full UI; steps 2-5 and 7 render a generic
placeholder using this same file's own `meta.stepDefinitions` (accessed via
`this.$options.meta` inside the component) until their UI is built out.
Individual Vue components that handle specific workflow steps:
### 2. The catalog (`@/workflows.js`)
#### Foto First Import Workflow
- **FotoFirstStep1.vue** - Photo capture/upload with camera and file upload support
- **FotoFirstStep2.vue** - Image processing with compression and optimization
- **FotoFirstStep3.vue** - Item details entry with form-based data collection
- **FotoFirstStep4.vue** - Import completion with summary and finalization
A single flat array assembled from:
- `implementedWorkflows` - workflows with a real component, built by
importing the component and reading its static `.meta` property:
`{ ...Component.meta, component: Component }`
- `plannedWorkflows` - workflows that exist conceptually (so they show up in
the catalog, can be started/tracked, and get a progress sidebar) but don't
have a dedicated UI yet; these are plain metadata objects with
`component: null`, defined directly in `workflows.js` since there's no
component file to attach `meta` to
#### Bulk Import Workflow
- **BulkImportStep1.vue** - File upload with CSV/Excel support and column mapping
Exposed functions:
- `getAllWorkflows()` - the full catalog, used by the `Workflows.vue` list
- `getWorkflow(id)` - a single workflow definition by id
- `getWorkflowComponent(id)` - the Vue component for a workflow, or `null`
- `getWorkflowsByCategory(category)` / `getWorkflowCategories()`
- `buildWorkflowApiPayload(workflow)` - builds the request body used to start
a new `WorkflowInstance`, merging the common `workflow_config` block with
the workflow's own `getInitialPayload()`
- `serializeWorkflowPayload(workflow)` / `deserializeWorkflowPayload(workflow)`
- JSON (de)serialization of `WorkflowInstance.payload`, also used by `store.js`
### 3. Dynamic Component Loading in WorkflowDetail.vue
The `stepComponent` computed property now uses the registry:
### 3. Usage in the views
`WorkflowDetail.vue`:
```javascript
stepComponent() {
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
workflowDefinition() {
// `name` on the WorkflowInstance doubles as the workflow type identifier
const workflowType = this.workflowInstance?.name;
if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep);
}
return null;
return getWorkflow(this.workflowInstance?.name);
},
workflowComponent() {
return getWorkflowComponent(this.workflowInstance?.name);
}
```
## How Content Dispatching Works
The resolved component is rendered once via `<component :is="workflowComponent">`
and receives the `step` prop on every step change - there's no need to swap
components as the user navigates between steps of the same workflow.
### 1. **Workflow Active State Management**
- Components connect to Vuex store's `active_workflows` state
- `loadWorkflowInstance()` fetches and finds specific workflow instances
- State determines which workflow type and step are active
`Workflows.vue` uses `getAllWorkflows()` for the catalog grid and
`buildWorkflowApiPayload(workflow)` when starting a new instance.
### 2. **Dynamic Component Resolution**
- System looks up components using workflow type + step combination
- Registry returns the appropriate Vue component or null
- Vue's `<component :is="stepComponent">` renders the resolved component
### 4. Component contract
### 3. **Component Communication**
- Step components receive props: `workflowInstance`, `step`, `payload`
- Components emit events: `@update`, `@next`, `@prev`, `@complete`
- Parent WorkflowDetail handles state updates and navigation
Every workflow component receives props `workflowInstance`, `step`, `payload`
and emits `update`, `next`, `prev`, `complete`. `WorkflowDetail.vue` handles
persisting payload updates (`handleStepUpdate`) and step navigation
(`handleNextStep` / `handlePrevStep` / `completeWorkflow`), independent of
which workflow is active.
### 4. **Workflow Active Classes**
- `isStepCurrent(stepNumber)` - Identifies active step
- `isStepCompleted(stepNumber)` - Tracks completed steps
- `getStepClass(stepNumber)` - Applies appropriate CSS classes:
- `step-indicator-completed bg-success` - Completed steps
- `step-indicator-current bg-primary text-white` - Current step
- `step-indicator-pending bg-light border` - Pending steps
### 5. Workflow progress sidebar
## Component Features
### FotoFirstStep1 (Photo Capture)
- **Camera Integration**: Uses `navigator.mediaDevices.getUserMedia()`
- **File Upload**: Drag-and-drop and file selection
- **Image Preview**: Real-time photo gallery
- **Data Persistence**: Photos saved to workflow payload
### FotoFirstStep2 (Image Processing)
- **Batch Processing**: Processes multiple images sequentially
- **Image Optimization**: Compression and resizing
- **Progress Tracking**: Visual progress indicators
- **Processing Options**: Configurable compression and dimensions
### FotoFirstStep3 (Item Details)
- **Item-by-Item Entry**: Navigate through captured photos
- **Comprehensive Forms**: Name, category, quantity, location, pricing
- **Progress Tracking**: Shows completion status
- **Data Validation**: Required field validation
- **Edit Support**: Ability to modify previously entered items
### FotoFirstStep4 (Completion)
- **Import Summary**: Statistics and breakdowns
- **Data Review**: Grid and list view of items
- **Final Options**: QR codes, notifications, reports
- **Completion Workflow**: Final import execution
### BulkImportStep1 (File Upload)
- **File Type Support**: CSV and Excel files
- **Drag-and-Drop**: Modern file upload interface
- **Column Mapping**: Automatic and manual field mapping
- **Data Preview**: Shows first 5 rows of data
- **Template Download**: Provides sample CSV template
- **File Analysis**: Validates data structure and content
Step metadata (name/description, total step count) comes from
`workflowDefinition.stepDefinitions` (the catalog entry, i.e. `Component.meta.stepDefinitions`),
used to render the progress sidebar and step list regardless of whether a
custom UI exists yet. `isStepCurrent(stepNumber)` / `isStepCompleted(stepNumber)` /
`getStepClass(stepNumber)` in `WorkflowDetail.vue` drive the sidebar styling.
## Extensibility
### Adding New Workflow Types
1. Create step components in `/components/workflow/steps/`
2. Import components in `ComponentRegistry.js`
3. Add mappings to `componentRegistry` object
4. Define workflow in `workflows.js`
### Adding UI for a planned workflow
1. Create `/components/workflow/workflows/MyWorkflow.vue`:
- Add a `meta: {...}` option to the exported component (copy the matching
entry out of `plannedWorkflows` in `workflows.js` and delete it there)
- Implement every step internally (`v-if="step === '1'"`, etc.)
2. In `workflows.js`, import the component and add
`{ ...MyWorkflow.meta, component: MyWorkflow }` to `implementedWorkflows`
### Adding New Steps
1. Create component: `WorkflowTypeStepN.vue`
2. Import and register in ComponentRegistry
3. Update workflow step definitions
4. Component automatically dispatched when step is reached
### Adding a brand new workflow type
1. Create the `.vue` file as above with a unique `id`
2. Add it to `implementedWorkflows` in `workflows.js` (if it has UI) or add a
plain metadata object to `plannedWorkflows` (if it doesn't, yet)
### Adding steps to an existing workflow
1. Add another `v-else-if="step === 'N'"` branch to the workflow's `.vue` file
2. Add the step to that same file's `meta.stepDefinitions`
3. Add any step-specific data/methods to the same file - no other file needs
to change
## Benefits
1. **Modularity**: Each step is an independent, reusable component
2. **Flexibility**: Easy to create workflow-specific UI experiences
3. **Maintainability**: Clear separation of concerns
4. **Extensibility**: Simple process to add new workflows and steps
5. **Type Safety**: Registry provides centralized component management
6. **Performance**: Components only loaded when needed
7. **Consistency**: Standardized props and events across all step components
1. **Single source of truth**: metadata and UI for a workflow live in one file
2. **No indirection**: one array, no separate component registry to keep in sync
3. **Cohesion**: all state and logic for a workflow's steps lives together
4. **Extensibility**: new workflows are a single new file + one array entry
5. **Consistency**: standardized props (`workflowInstance`, `step`, `payload`)
and events (`update`, `next`, `prev`, `complete`) across all workflow components
## Usage Example
```javascript
// Register a new step component
registerStepComponent('custom-workflow', '1', CustomStep1Component);
// Check if component exists
if (hasStepComponent('foto-first-bulk-import', '1')) {
// Component is available
}
// Get all components for a workflow
const components = getWorkflowComponents('import-items');
```
This system provides a robust foundation for building complex, multi-step workflows with rich, interactive user interfaces while maintaining clean separation between workflow logic and presentation components.

View file

@ -1,495 +0,0 @@
<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: [],
parsedRows: [],
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.file_name) {
this.uploadTime = this.payload.upload_time;
this.fileAnalysis = this.payload.file_analysis;
this.detectedColumns = this.payload.detected_columns || [];
this.parsedRows = this.payload.parsed_rows || [];
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) {
// Parsing happens entirely client-side. The backend never sees the
// raw file - only the structured `items` list produced here, sent
// later as generic payload/request body.
if (file.name.endsWith('.csv')) {
const text = await file.text();
this.parsedRows = this.parseCsv(text);
} else {
// For Excel files, you'd use a library like SheetJS to parse
// client-side. This is a mock implementation.
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
this.parsedRows = [
{ 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.previewData = this.parsedRows.slice(0, 5);
this.fileAnalysis = {
totalRows: this.parsedRows.length,
totalColumns: this.detectedColumns.length,
validRows: this.parsedRows.length,
errorRows: 0
};
// Auto-map columns based on common names
this.autoMapColumns();
},
// Simple CSV parser: no external dependency, entirely client-side.
// Returns an array of row objects keyed by the detected header columns.
parseCsv(text) {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) {
this.detectedColumns = [];
return [];
}
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
this.detectedColumns = headers;
return lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
},
// Convert the fully parsed rows + column mapping into the generic item
// dicts consumed by the "Import Items" step, which creates each one via
// the standard generic InventoryItem create endpoint.
buildItemsFromMapping() {
return this.parsedRows.map(row => ({
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
description: this.columnMapping.description ? row[this.columnMapping.description] : '',
category: this.columnMapping.category ? row[this.columnMapping.category] : '',
quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
})).filter(item => item.name);
},
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.parsedRows = [];
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', {
file_name: this.uploadedFile?.name || null,
upload_time: this.uploadTime,
file_analysis: this.fileAnalysis,
detected_columns: this.detectedColumns,
parsed_rows: this.parsedRows,
preview_data: this.previewData,
column_mapping: this.columnMapping,
items: this.buildItemsFromMapping()
});
},
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>

View file

@ -1,124 +0,0 @@
<template>
<div class="bulk-import-step-6">
<div class="step-header mb-4">
<h4 class="mb-2">Import Items</h4>
<p class="text-muted">
Create inventory items from the rows parsed in the File Upload step.
The file itself was never sent to the server - only these structured
item rows are.
</p>
</div>
<div v-if="!results" class="card mb-4">
<div class="card-body text-center py-4">
<p class="mb-3">
<strong>{{ items.length }}</strong> item(s) ready to import.
</p>
<button class="btn btn-primary" :disabled="importing || items.length === 0" @click="runImport">
<span v-if="importing" class="spinner-border spinner-border-sm me-2"></span>
Import {{ items.length }} item(s)
</button>
<div v-if="error" class="alert alert-danger mt-3 mb-0">{{ error }}</div>
</div>
</div>
<div v-else class="card mb-4">
<div class="card-body">
<h6 class="mb-2">Import complete</h6>
<p class="mb-2">
<span class="badge bg-success me-2">{{ results.created_count }} created</span>
<span v-if="results.errors && results.errors.length" class="badge bg-warning text-dark">
{{ results.errors.length }} issue(s)
</span>
</p>
<ul v-if="results.errors && results.errors.length" class="small text-muted mb-0">
<li v-for="(err, index) in results.errors" :key="index">{{ err }}</li>
</ul>
</div>
</div>
<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-primary" :disabled="!results" @click="$emit('next')">
Next: Generate Report
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import { mapActions } from 'vuex';
export default {
name: 'BulkImportStep6',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
importing: false,
error: null,
results: this.payload.import_results || null
}
},
computed: {
items() {
return this.payload.items || [];
}
},
methods: {
...mapActions(['createInventoryItem']),
async runImport() {
this.importing = true;
this.error = null;
const created_item_ids = [];
const errors = [];
// Items were fully parsed client-side (File Upload step). The backend
// never sees the source file or any bulk-import-specific endpoint -
// each row is created through the same generic
// POST /api/inventory_items/ endpoint any other client would use.
for (const [index, row] of this.items.entries()) {
if (!row.name) {
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
continue;
}
try {
const created = await this.createInventoryItem({
name: row.name,
description: row.description || '',
owned_quantity: parseInt(row.quantity) || 1,
...(row.category ? {category: row.category} : {})
});
created_item_ids.push(created.id);
} catch (err) {
console.error('Failed to create item:', row, err);
errors.push(`Row ${index + 1} ("${row.name}"): failed to create.`);
}
}
this.results = {created_item_ids, created_count: created_item_ids.length, errors};
this.$emit('update', {import_results: this.results});
this.importing = false;
}
}
}
</script>

View file

@ -1,274 +0,0 @@
<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>

View file

@ -1,360 +0,0 @@
<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>

View file

@ -1,419 +0,0 @@
<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>

View file

@ -1,376 +0,0 @@
<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>

View file

@ -0,0 +1,125 @@
<template>
<div class="backup-restore-workflow">
<div class="step-header mb-4">
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
</div>
<!-- Step Progress -->
<div class="progress mb-4" style="height: 8px;">
<div
class="progress-bar"
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
></div>
</div>
<!-- Generic Step Content -->
<div class="card mb-4">
<div class="card-body text-center py-5">
<b-icon-download class="text-muted mb-3" style="font-size: 3rem;"></b-icon-download>
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
<p class="text-muted">Implementation coming soon...</p>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button
v-if="parseInt(step) > 1"
class="btn btn-outline-secondary"
@click="$emit('prev')"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div v-else></div>
<button
v-if="parseInt(step) < meta.stepDefinitions.length"
class="btn btn-primary"
@click="$emit('next')"
>
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button
v-else
class="btn btn-success"
@click="$emit('complete')"
>
Complete
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from 'bootstrap-icons-vue';
export default {
name: 'BackupRestoreWorkflow',
meta: {
id: 'backup-restore',
name: 'Data Backup',
category: 'System Maintenance',
description: 'Create a comprehensive backup of your inventory and settings data.',
icons: ['b-icon-gear', 'b-icon-download'],
estimatedDuration: '15 minutes',
stepDefinitions: [
{ step: '1', name: 'Prepare Backup', description: 'Initialize backup process and verify system' },
{ step: '2', name: 'Export Data', description: 'Export inventory, settings, and user data' },
{ step: '3', name: 'Finalize Backup', description: 'Compress and store backup file' }
],
getInitialPayload() {
return {
backup_options: {
include_inventory: true,
include_settings: true,
include_user_data: true,
include_files: false,
compression: true
}
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
methods: {
getCurrentStepName() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.name : `Step ${this.step}`;
},
getCurrentStepDescription() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.description : '';
}
}
}
</script>
<style scoped>
.backup-restore-workflow {
padding: 1rem;
}
.step-navigation {
margin-top: 2rem;
}
</style>

View file

@ -0,0 +1,655 @@
<template>
<div class="bulk-item-import-workflow">
<!-- Step 1: File Upload -->
<div v-if="step === '1'" 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="processingFile" 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 && !processingFile" 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="proceedFromStep1"
:disabled="!canProceedFromStep1"
>
Next: Parse Data
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
<!-- Step 6: Import Items -->
<div v-else-if="step === '6'" class="bulk-import-step-6">
<div class="step-header mb-4">
<h4 class="mb-2">Import Items</h4>
<p class="text-muted">
Create inventory items from the rows parsed in the File Upload step.
The file itself was never sent to the server - only these structured
item rows are.
</p>
</div>
<div v-if="!importResults" class="card mb-4">
<div class="card-body text-center py-4">
<p class="mb-3">
<strong>{{ items.length }}</strong> item(s) ready to import.
</p>
<button class="btn btn-primary" :disabled="importing || items.length === 0" @click="runImport">
<span v-if="importing" class="spinner-border spinner-border-sm me-2"></span>
Import {{ items.length }} item(s)
</button>
<div v-if="importError" class="alert alert-danger mt-3 mb-0">{{ importError }}</div>
</div>
</div>
<div v-else class="card mb-4">
<div class="card-body">
<h6 class="mb-2">Import complete</h6>
<p class="mb-2">
<span class="badge bg-success me-2">{{ importResults.created_count }} created</span>
<span v-if="importResults.errors && importResults.errors.length" class="badge bg-warning text-dark">
{{ importResults.errors.length }} issue(s)
</span>
</p>
<ul v-if="importResults.errors && importResults.errors.length" class="small text-muted mb-0">
<li v-for="(err, index) in importResults.errors" :key="index">{{ err }}</li>
</ul>
</div>
</div>
<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-primary" :disabled="!importResults" @click="$emit('next')">
Next: Generate Report
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
<!-- Fallback for not-yet-implemented steps (2-5, 7) -->
<div v-else class="text-center py-5">
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
<h5 class="text-muted">{{ currentStepDefinition?.name || `Step ${step}` }}</h5>
<p class="text-muted">{{ currentStepDefinition?.description || 'This step is in progress.' }}</p>
<div class="mt-4">
<button class="btn btn-outline-secondary me-2" @click="$emit('prev')">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button class="btn btn-primary" @click="$emit('next')">
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import { mapActions } from 'vuex';
/**
* Bulk Item Import Workflow
*
* This single component implements every step of the 'import-items' workflow.
* Steps 1 (File Upload) and 6 (Import Items) have fully custom UI; the
* remaining steps (2-5, 7) currently fall back to a generic "in progress"
* placeholder driven by this workflow's own step metadata, but can be
* fleshed out here later without touching any other file.
*/
export default {
name: 'BulkItemImportWorkflow',
// Metadata describing this workflow, co-located with its implementation
// so there is a single source of truth per workflow type. Consumed by
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
// by the Workflows and WorkflowDetail views.
meta: {
id: 'import-items',
name: 'Bulk Item Import',
category: 'Data Management',
description: 'Import multiple inventory items from CSV or Excel files with validation.',
icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],
estimatedDuration: '20-60 minutes',
stepDefinitions: [
{ step: '1', name: 'File Upload', description: 'Upload CSV or Excel file' },
{ step: '2', name: 'Parse Data', description: 'Parse and analyze file contents' },
{ step: '3', name: 'Validate Format', description: 'Validate data format and structure' },
{ step: '4', name: 'Data Validation', description: 'Validate individual item data' },
{ step: '5', name: 'Conflict Resolution', description: 'Resolve any data conflicts' },
{ step: '6', name: 'Import Items', description: 'Import validated items into system' },
{ step: '7', name: 'Generate Report', description: 'Generate import summary report' }
],
getInitialPayload() {
return {
import_options: {
file_type: null,
skip_duplicates: true,
update_existing: false,
validate_required_fields: true
},
mapping: {},
validation_results: []
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
// Step 1: file upload
uploadedFile: null,
uploadTime: null,
processingFile: false,
processingStatus: '',
isDragOver: false,
fileAnalysis: null,
detectedColumns: [],
parsedRows: [],
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 }
],
// Step 6: import items
importing: false,
importError: null,
importResults: this.payload.import_results || null
}
},
computed: {
canProceedFromStep1() {
return this.uploadedFile && this.fileAnalysis && this.columnMapping.name;
},
items() {
return this.payload.items || [];
},
currentStepDefinition() {
return this.$options.meta.stepDefinitions.find(s => s.step === this.step);
}
},
mounted() {
// Step 1: load existing data if resuming
if (this.payload.file_name) {
this.uploadTime = this.payload.upload_time;
this.fileAnalysis = this.payload.file_analysis;
this.detectedColumns = this.payload.detected_columns || [];
this.parsedRows = this.payload.parsed_rows || [];
this.previewData = this.payload.preview_data || [];
this.columnMapping = this.payload.column_mapping || {};
}
},
methods: {
...mapActions(['createInventoryItem']),
// --- Step 1: File upload ---
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.processingFile = true;
this.processingStatus = 'Reading file...';
try {
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.updateStep1Payload();
} catch (error) {
console.error('Error processing file:', error);
alert('Error processing file. Please try again.');
this.removeFile();
} finally {
this.processingFile = false;
this.processingStatus = '';
}
},
async analyzeFile(file) {
// Parsing happens entirely client-side. The backend never sees the
// raw file - only the structured `items` list produced here, sent
// later as generic payload/request body.
if (file.name.endsWith('.csv')) {
const text = await file.text();
this.parsedRows = this.parseCsv(text);
} else {
// For Excel files, you'd use a library like SheetJS to parse
// client-side. This is a mock implementation.
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
this.parsedRows = [
{ 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.previewData = this.parsedRows.slice(0, 5);
this.fileAnalysis = {
totalRows: this.parsedRows.length,
totalColumns: this.detectedColumns.length,
validRows: this.parsedRows.length,
errorRows: 0
};
this.autoMapColumns();
},
// Simple CSV parser: no external dependency, entirely client-side.
parseCsv(text) {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) {
this.detectedColumns = [];
return [];
}
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
this.detectedColumns = headers;
return lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
},
// Convert the fully parsed rows + column mapping into the generic item
// dicts consumed by the "Import Items" step, which creates each one via
// the standard generic InventoryItem create endpoint.
buildItemsFromMapping() {
return this.parsedRows.map(row => ({
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
description: this.columnMapping.description ? row[this.columnMapping.description] : '',
category: this.columnMapping.category ? row[this.columnMapping.category] : '',
quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
})).filter(item => item.name);
},
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.parsedRows = [];
this.previewData = [];
this.columnMapping = {};
this.updateStep1Payload();
},
downloadTemplate() {
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);
},
updateStep1Payload() {
this.$emit('update', {
file_name: this.uploadedFile?.name || null,
upload_time: this.uploadTime,
file_analysis: this.fileAnalysis,
detected_columns: this.detectedColumns,
parsed_rows: this.parsedRows,
preview_data: this.previewData,
column_mapping: this.columnMapping,
items: this.buildItemsFromMapping()
});
},
proceedFromStep1() {
if (!this.canProceedFromStep1) {
alert('Please upload a file and map the required Name column before proceeding.');
return;
}
this.updateStep1Payload();
this.$emit('next');
},
// --- Step 6: Import items ---
async runImport() {
this.importing = true;
this.importError = null;
const created_item_ids = [];
const errors = [];
// Items were fully parsed client-side (File Upload step). The backend
// never sees the source file or any bulk-import-specific endpoint -
// each row is created through the same generic
// POST /api/inventory_items/ endpoint any other client would use.
for (const [index, row] of this.items.entries()) {
if (!row.name) {
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
continue;
}
try {
const created = await this.createInventoryItem({
name: row.name,
description: row.description || '',
owned_quantity: parseInt(row.quantity) || 1,
...(row.category ? {category: row.category} : {})
});
created_item_ids.push(created.id);
} catch (err) {
console.error('Failed to create item:', row, err);
errors.push(`Row ${index + 1} ("${row.name}"): failed to create.`);
}
}
this.importResults = {created_item_ids, created_count: created_item_ids.length, errors};
this.$emit('update', {import_results: this.importResults});
this.importing = false;
}
}
}
</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>

View file

@ -0,0 +1,126 @@
<template>
<div class="expiry-check-workflow">
<div class="step-header mb-4">
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
</div>
<!-- Step Progress -->
<div class="progress mb-4" style="height: 8px;">
<div
class="progress-bar"
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
></div>
</div>
<!-- Generic Step Content -->
<div class="card mb-4">
<div class="card-body text-center py-5">
<b-icon-clock-history class="text-muted mb-3" style="font-size: 3rem;"></b-icon-clock-history>
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
<p class="text-muted">Implementation coming soon...</p>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button
v-if="parseInt(step) > 1"
class="btn btn-outline-secondary"
@click="$emit('prev')"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div v-else></div>
<button
v-if="parseInt(step) < meta.stepDefinitions.length"
class="btn btn-primary"
@click="$emit('next')"
>
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button
v-else
class="btn btn-success"
@click="$emit('complete')"
>
Complete
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from 'bootstrap-icons-vue';
export default {
name: 'ExpiryCheckWorkflow',
meta: {
id: 'expiry-check',
name: 'Expiry Date Check',
category: 'Quality Control',
description: 'Identify and handle items approaching or past their expiry dates.',
icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'],
estimatedDuration: '45 minutes',
stepDefinitions: [
{ step: '1', name: 'Scan Expiry Dates', description: 'Check all items for expiry information' },
{ step: '2', name: 'Identify Critical Items', description: 'Find expired and soon-to-expire items' },
{ step: '3', name: 'Assess Item Condition', description: 'Evaluate condition of critical items' },
{ step: '4', name: 'Generate Action Plan', description: 'Create disposal or usage recommendations' },
{ step: '5', name: 'Execute Actions', description: 'Implement recommended actions' },
{ step: '6', name: 'Update Records', description: 'Update item statuses and records' }
],
getInitialPayload() {
return {
check_parameters: {
warning_days: 30,
include_no_expiry: false,
categories: []
}
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
methods: {
getCurrentStepName() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.name : `Step ${this.step}`;
},
getCurrentStepDescription() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.description : '';
}
}
}
</script>
<style scoped>
.expiry-check-workflow {
padding: 1rem;
}
.step-navigation {
margin-top: 2rem;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
<template>
<div class="inventory-audit-workflow">
<div class="step-header mb-4">
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
</div>
<!-- Step Progress -->
<div class="progress mb-4" style="height: 8px;">
<div
class="progress-bar"
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
></div>
</div>
<!-- Generic Step Content -->
<div class="card mb-4">
<div class="card-body text-center py-5">
<b-icon-list-ul class="text-muted mb-3" style="font-size: 3rem;"></b-icon-list-ul>
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
<p class="text-muted">Implementation coming soon...</p>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button
v-if="parseInt(step) > 1"
class="btn btn-outline-secondary"
@click="$emit('prev')"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div v-else></div>
<button
v-if="parseInt(step) < meta.stepDefinitions.length"
class="btn btn-primary"
@click="$emit('next')"
>
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button
v-else
class="btn btn-success"
@click="$emit('complete')"
>
Complete
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from 'bootstrap-icons-vue';
export default {
name: 'InventoryAuditWorkflow',
meta: {
id: 'inventory-audit',
name: 'Inventory Audit',
category: 'Inventory Management',
description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
icons: ['b-icon-list-ul'],
estimatedDuration: '2-4 hours',
stepDefinitions: [
{ step: '1', name: 'Initialize Audit', description: 'Set up audit parameters and scope' },
{ step: '2', name: 'Generate Item List', description: 'Create list of items to audit' },
{ step: '3', name: 'Location Verification', description: 'Verify item locations' },
{ step: '4', name: 'Quantity Count', description: 'Count physical quantities' },
{ step: '5', name: 'Condition Assessment', description: 'Assess item conditions' },
{ step: '6', name: 'Discrepancy Detection', description: 'Identify discrepancies' },
{ step: '7', name: 'Report Generation', description: 'Generate audit report' },
{ step: '8', name: 'Finalize Audit', description: 'Complete and archive audit' }
],
getInitialPayload() {
return {
audit_parameters: {
scope: 'full',
include_all_categories: true
}
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
methods: {
getCurrentStepName() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.name : `Step ${this.step}`;
},
getCurrentStepDescription() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.description : '';
}
}
}
</script>
<style scoped>
.inventory-audit-workflow {
padding: 1rem;
}
.step-navigation {
margin-top: 2rem;
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<div class="maintenance-schedule-workflow">
<div class="step-header mb-4">
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
</div>
<!-- Step Progress -->
<div class="progress mb-4" style="height: 8px;">
<div
class="progress-bar"
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
></div>
</div>
<!-- Generic Step Content -->
<div class="card mb-4">
<div class="card-body text-center py-5">
<b-icon-tools class="text-muted mb-3" style="font-size: 3rem;"></b-icon-tools>
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
<p class="text-muted">Implementation coming soon...</p>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button
v-if="parseInt(step) > 1"
class="btn btn-outline-secondary"
@click="$emit('prev')"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div v-else></div>
<button
v-if="parseInt(step) < meta.stepDefinitions.length"
class="btn btn-primary"
@click="$emit('next')"
>
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button
v-else
class="btn btn-success"
@click="$emit('complete')"
>
Complete
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from 'bootstrap-icons-vue';
export default {
name: 'MaintenanceScheduleWorkflow',
meta: {
id: 'maintenance-schedule',
name: 'Maintenance Schedule',
category: 'Tool Maintenance',
description: 'Create and execute maintenance schedules for tools and equipment.',
icons: ['b-icon-tools', 'b-icon-calendar'],
estimatedDuration: '30 minutes',
stepDefinitions: [
{ step: '1', name: 'Identify Equipment', description: 'Select tools and equipment for maintenance' },
{ step: '2', name: 'Create Schedule', description: 'Define maintenance intervals and tasks' },
{ step: '3', name: 'Assign Responsibilities', description: 'Assign maintenance tasks to users' },
{ step: '4', name: 'Activate Schedule', description: 'Enable automatic maintenance reminders' }
],
getInitialPayload() {
return {
schedule_config: {
frequency: 'monthly'
}
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
methods: {
getCurrentStepName() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.name : `Step ${this.step}`;
},
getCurrentStepDescription() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.description : '';
}
}
}
</script>
<style scoped>
.maintenance-schedule-workflow {
padding: 1rem;
}
.step-navigation {
margin-top: 2rem;
}
</style>

View file

@ -0,0 +1,123 @@
<template>
<div class="storage-optimization-workflow">
<div class="step-header mb-4">
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
</div>
<!-- Step Progress -->
<div class="progress mb-4" style="height: 8px;">
<div
class="progress-bar"
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
></div>
</div>
<!-- Generic Step Content -->
<div class="card mb-4">
<div class="card-body text-center py-5">
<b-icon-boxes class="text-muted mb-3" style="font-size: 3rem;"></b-icon-boxes>
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
<p class="text-muted">Implementation coming soon...</p>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button
v-if="parseInt(step) > 1"
class="btn btn-outline-secondary"
@click="$emit('prev')"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div v-else></div>
<button
v-if="parseInt(step) < meta.stepDefinitions.length"
class="btn btn-primary"
@click="$emit('next')"
>
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button
v-else
class="btn btn-success"
@click="$emit('complete')"
>
Complete
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from 'bootstrap-icons-vue';
export default {
name: 'StorageOptimizationWorkflow',
meta: {
id: 'storage-optimization',
name: 'Storage Optimization',
category: 'Storage Management',
description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],
estimatedDuration: '1-2 hours',
stepDefinitions: [
{ step: '1', name: 'Analyze Current Layout', description: 'Assess current storage efficiency' },
{ step: '2', name: 'Identify Optimization Opportunities', description: 'Find areas for improvement' },
{ step: '3', name: 'Plan Reorganization', description: 'Create optimization plan' },
{ step: '4', name: 'Execute Changes', description: 'Implement storage changes' },
{ step: '5', name: 'Validate Results', description: 'Verify optimization results' }
],
getInitialPayload() {
return {
optimization_params: {
strategy: 'efficiency'
}
};
}
},
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
methods: {
getCurrentStepName() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.name : `Step ${this.step}`;
},
getCurrentStepDescription() {
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
return stepDef ? stepDef.description : '';
}
}
}
</script>
<style scoped>
.storage-optimization-workflow {
padding: 1rem;
}
.step-navigation {
margin-top: 2rem;
}
</style>

View file

@ -10,6 +10,7 @@ import Friends from '@/views/Friends.vue';
import Inventory from '@/views/Inventory.vue';
import Search from '@/views/Search.vue';
import InventoryDetail from '@/views/InventoryDetail.vue';
import InventoryDetailForeign from '@/views/InventoryDetailForeign.vue';
import InventoryNew from '@/views/InventoryNew.vue';
import InventoryEdit from '@/views/InventoryEdit.vue';
import StorageLocation from '@/views/StorageLocation.vue';
@ -45,6 +46,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: InventoryEdit,
meta: {requiresAuth: true},
props: true
}, {
path: '/inventory/shared/:user/:id',
component: InventoryDetailForeign,
meta: {requiresAuth: true, foreign: true},
props: true
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
path: '/friends',
component: Friends,

View file

@ -0,0 +1,102 @@
<template>
<BaseLayout>
<main class="content">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">{{ item.name }}</div>
<div class="card-body">
<div class="mb-3">
<label for="description" class="form-label">Description</label>
Foreighn {{ item.description }}
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<span class="badge bg-dark" v-for="(tag, index) in item.tags" :key="index">
{{ getNameFromHandle(tag) }}
</span>
</div>
<div class="mb-3">
<label for="property" class="form-label">Properties</label>
<span class="badge bg-dark" v-for="(property, index) in item.properties" :key="index">
{{ property.name }}={{ property.value }}
</span>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
{{ item.owned_quantity }}
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<div>
<authenticated-image v-for="file in item.files" :key="file.id" :src="file.name"
:owner="file.owner" class="img-thumbnail border-info"></authenticated-image>
</div>
</div>
</div>
</div>
<div class="card">
<button class="btn btn-primary" @click="$router.push('/inventory/' + id + '/edit')">
<b-icon-pencil-square></b-icon-pencil-square>
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
<b-icon-trash></b-icon-trash>
Delete
</button>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
export default {
name: "InventoryDetail",
components: {
AuthenticatedImage,
BaseLayout,
...BIcons
},
props: {
id: {
type: String,
required: true
}
},
computed: {
...mapGetters(["loaded_items", "getNameFromHandle"]),
...mapState(["storage_locations"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
}
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
}
}
</script>
<style scoped>
img {
width: 190px;
height: 107px;
object-fit: contain;
}
</style>

View file

@ -27,20 +27,13 @@
<tbody>
<tr v-for="item in search_results" :key="item.id">
<td>
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
<router-link :to="`/inventory/${item.handle}`">{{ item.name }}</router-link>
</td>
<td>
<user-name-tag :user="item"/>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<!--<router-link :to="`/inventory/${item.id}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`/inventory/${item.id}/delete`"
@click.prevent="deleteInventoryItem(item)">
<b-icon-trash></b-icon-trash>
</a>-->
</td>
</tr>
</tbody>
@ -56,7 +49,7 @@
class="card-img-top img-preview"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="`/inventory/${item.id}`">
<router-link :to="`/inventory/${item.handle}`">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
@ -78,7 +71,7 @@
</template>
<script>
import {mapActions} from 'vuex';
import {mapActions, mapState} from 'vuex';
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import SearchBox from "@/components/SearchBox.vue";
@ -114,10 +107,13 @@ export default {
},
loadResults() {
this.fetchSearchResults({query: this.query}).then((results) => {
this.search_results = results;
this.search_results = results.map(e=>({...e, handle: e.owner==this.user?e.id:"shared/"+e.owner+"/"+e.id}));
});
}
},
computed: {
...mapState(['user'])
},
watch: {
query: {
immediate: false,

View file

@ -142,14 +142,15 @@
<div class="card-body">
<!-- Step-specific content based on workflow type and current step -->
<component
v-if="stepComponent"
:is="stepComponent"
v-if="workflowComponent"
:is="workflowComponent"
:workflow-instance="workflowInstance"
:step="currentStep"
:payload="workflowInstance.payload"
@update="handleStepUpdate"
@next="handleNextStep"
@prev="handlePrevStep"
@complete="completeWorkflow"
/>
<!-- Default step content if no specific component -->
@ -201,8 +202,7 @@
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import { mapState, mapActions } from 'vuex';
import { workflowRegistry } from '@/workflows.js';
import { getStepComponent, hasStepComponent } from '@/components/workflow/ComponentRegistry.js';
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
export default {
name: 'WorkflowDetail',
@ -238,11 +238,11 @@ export default {
workflowDefinition() {
// `name` doubles as the workflow type identifier (e.g. 'import-items').
if (!this.workflowInstance?.name) return null;
return workflowRegistry.get(this.workflowInstance.name);
return getWorkflow(this.workflowInstance.name);
},
stepDefinitions() {
return this.workflowDefinition?.getStepDefinitions() || [];
return this.workflowDefinition?.stepDefinitions || [];
},
totalSteps() {
@ -257,13 +257,12 @@ export default {
return this.workflowInstance?.state === 'running';
},
stepComponent() {
// Return step-specific component if it exists using the component registry
workflowComponent() {
// Return the single component implementing the whole workflow, if any,
// using the workflow component registry. The component itself decides
// what to render based on the `step` prop it receives.
const workflowType = this.workflowInstance?.name;
if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep);
}
return null;
return workflowType ? getWorkflowComponent(workflowType) : null;
},
currentStepIndex() {
@ -282,8 +281,8 @@ export default {
},
hasCustomComponent() {
// Check if the current step has a custom component defined
return this.stepComponent !== null;
// Check if the current workflow has a custom component defined
return this.workflowComponent !== null;
},
componentStatusClass() {
@ -463,12 +462,6 @@ export default {
formatDateTime(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
},
hasStepComponent(stepNumber) {
// Check if a specific step has a custom component available
const workflowType = this.workflowInstance?.name;
return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
}
}
}

View file

@ -128,7 +128,7 @@
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import { mapState, mapActions } from 'vuex';
import { workflowRegistry } from '@/workflows.js';
import { getAllWorkflows, getWorkflow, buildWorkflowApiPayload } from '@/workflows.js';
export default {
name: 'Workflows',
@ -148,7 +148,7 @@ export default {
return this.active_workflows;
},
availableWorkflows() {
return workflowRegistry.getAll();
return getAllWorkflows();
}
},
async mounted() {
@ -221,22 +221,22 @@ export default {
},
getWorkflowDisplayName(workflow) {
// `name` doubles as the workflow type identifier (e.g. 'import-items').
return workflowRegistry.get(workflow.name)?.name || workflow.name;
return getWorkflow(workflow.name)?.name || workflow.name;
},
async startWorkflow(workflow) {
try {
this.loading = true;
this.error = null;
// Use the workflow's toApiFormat method to get properly formatted data
const workflowData = workflow.toApiFormat();
// Build the properly formatted data to start this workflow
const workflowData = buildWorkflowApiPayload(workflow);
const newWorkflow = await this.createWorkflow(workflowData);
console.log('Workflow started successfully:', newWorkflow);
// Immediately navigate to the workflow detail view
// Get the first step from the workflow definition
const firstStep = workflow.getStepDefinitions()?.[0]?.step || "initial";
const firstStep = workflow.stepDefinitions?.[0]?.step || "initial";
this.$router.push({
name: 'workflow-detail',
params: {
@ -258,7 +258,7 @@ export default {
// Use the workflow's current step if available, otherwise use the first step
const currentStep = workflow.current_step ||
workflow.payload?.current_step ||
workflow.getStepDefinitions?.()?.[0]?.step ||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
"initial";
this.$router.push({

View file

@ -1,409 +1,110 @@
/**
* Common Workflow Interface
*
* All workflow classes should implement this interface:
*
* interface IWorkflow {
* id: string; // Unique identifier for the workflow type
* name: string; // Display name for the workflow
* category: string; // Category grouping (e.g., 'Data Management', 'Inventory Management')
* description: string; // Detailed description of what the workflow does
* icons: Array<string>; // Array of Bootstrap icon component names
* estimatedDuration: string; // Human-readable duration estimate
* steps: number; // Total number of steps in the workflow
*
* // Methods
* validate(): boolean; // Validate if workflow can be started
* getStepDefinitions(): Array; // Get array of step definitions
* getInitialPayload(): Object; // Get initial payload structure
* }
* Workflow Catalog
*
* Single source of truth for every workflow type known to the frontend:
* what it's called, what category/description/icons it has, how many steps
* it has and what they're called, what its initial payload looks like, and
* which Vue component renders it.
*
* Each workflow has a fully co-located component + metadata as a static
* `meta` option on the component (`Component.meta`, right next to
* `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - this
* file simply imports those components and reads `.meta` off of them to
* build the catalog below.
*
* This replaces the previous design of a parallel `BaseWorkflow` class
* hierarchy (metadata) plus a separate per-step `ComponentRegistry.js`
* (components) - both concerns now live in one flat array with each
* component responsible for its own metadata and UI implementation.
*/
import FotoFirstBulkImportWorkflow from '@/components/workflow/workflows/FotoFirstBulkImportWorkflow.vue';
import BulkItemImportWorkflow from '@/components/workflow/workflows/BulkItemImportWorkflow.vue';
import InventoryAuditWorkflow from '@/components/workflow/workflows/InventoryAuditWorkflow.vue';
import StorageOptimizationWorkflow from '@/components/workflow/workflows/StorageOptimizationWorkflow.vue';
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
/**
* Base workflow class that all workflows extend
* Workflows with a fully co-located component + metadata.
*/
class BaseWorkflow {
constructor(id, name, category, description, icons, estimatedDuration, steps) {
this.id = id;
this.name = name;
this.category = category;
this.description = description;
// Ensure icon is always an array - convert single string to array if needed
this.icons = Array.isArray(icons) ? icons : [icons];
this.estimatedDuration = estimatedDuration;
this.steps = steps;
}
/**
* Validate if the workflow can be started
* Override in subclasses for specific validation logic
*/
validate() {
return true;
}
/**
* Get the initial payload structure for this workflow
* Override in subclasses to provide workflow-specific payload
*/
getInitialPayload() {
return {
const implementedWorkflows = [
{ ...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow },
{ ...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow },
{ ...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow },
{ ...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow },
{ ...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow },
{ ...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow },
{ ...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow },
];
/**
* The full workflow catalog: every workflow type known to the frontend,
* whether it has a custom UI or not.
*/
const workflows = implementedWorkflows;
/**
* Get every workflow in the catalog.
* @returns {Array<Object>}
*/
export function getAllWorkflows() {
return workflows;
}
/**
* Get a single workflow definition by id.
* @param {string} id
* @returns {Object|undefined}
*/
export function getWorkflow(id) {
return workflows.find(workflow => workflow.id === id);
}
/**
* Get the Vue component implementing a workflow's UI, if any.
* @param {string} id
* @returns {Object|null}
*/
export function getWorkflowComponent(id) {
return getWorkflow(id)?.component || null;
}
/**
* Get all workflows belonging to a category.
* @param {string} category
* @returns {Array<Object>}
*/
export function getWorkflowsByCategory(category) {
return workflows.filter(workflow => workflow.category === category);
}
/**
* Get all unique categories present in the catalog.
* @returns {Array<string>}
*/
export function getWorkflowCategories() {
return [...new Set(workflows.map(workflow => workflow.category))];
}
/**
* Build the payload sent to the backend to start a new instance of a
* workflow, merging the common `workflow_config` metadata block with the
* workflow's own initial payload fields.
* @param {Object} workflow - A workflow definition, e.g. from getWorkflow()
* @returns {Object}
*/
export function buildWorkflowApiPayload(workflow) {
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
return {
name: workflow.name,
workflow_type: workflow.id,
state: 'running',
current_step: 1,
total_steps: workflow.stepDefinitions.length,
payload: {
workflow_config: {
name: this.name,
description: this.description,
category: this.category,
estimated_duration: this.estimatedDuration
}
};
}
/**
* Get step definitions for this workflow
* Override in subclasses to provide workflow-specific steps
*/
getStepDefinitions() {
return [];
}
/**
* Convert workflow to API-compatible format
*/
toApiFormat() {
return {
name: this.name,
workflow_type: this.id,
state: 'running',
current_step: 1,
total_steps: this.steps,
payload: this.getInitialPayload()
};
}
}
/**
* Foto First Import Workflow
* Captures photos via mobile camera or upload, then sequentially enters details for each item
*/
class FotoFirstImportWorkflow extends BaseWorkflow {
constructor() {
super(
'foto-first-bulk-import',
'Foto First Bulk Import',
'Data Management',
'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.',
['b-icon-camera', 'b-icon-pencil-square'],
'10-60 minutes',
4
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Photo Capture', description: 'Capture or upload item photos' },
{ step: 2, name: 'Image Processing', description: 'Process and optimize images' },
{ step: 3, name: 'Item Details Entry', description: 'Enter details for each photographed item' },
{ step: 4, name: 'Import Completion', description: 'Finalize and save imported items' }
];
}
getInitialPayload() {
return {
...super.getInitialPayload(),
photos: [],
processing_options: {
auto_rotate: true,
compress: true,
max_width: 1920,
max_height: 1080
}
};
}
}
/**
* Bulk Item Import Workflow
* Imports multiple inventory items from CSV or Excel files with validation
*/
class BulkItemImportWorkflow extends BaseWorkflow {
constructor() {
super(
'import-items',
'Bulk Item Import',
'Data Management',
'Import multiple inventory items from CSV or Excel files with validation.',
['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],
'20-60 minutes',
7
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'File Upload', description: 'Upload CSV or Excel file' },
{ step: 2, name: 'Parse Data', description: 'Parse and analyze file contents' },
{ step: 3, name: 'Validate Format', description: 'Validate data format and structure' },
{ step: 4, name: 'Data Validation', description: 'Validate individual item data' },
{ step: 5, name: 'Conflict Resolution', description: 'Resolve any data conflicts' },
{ step: 6, name: 'Import Items', description: 'Import validated items into system' },
{ step: 7, name: 'Generate Report', description: 'Generate import summary report' }
];
}
getInitialPayload() {
return {
...super.getInitialPayload(),
import_options: {
file_type: null,
skip_duplicates: true,
update_existing: false,
validate_required_fields: true
name: workflow.name,
description: workflow.description,
category: workflow.category,
estimated_duration: workflow.estimatedDuration
},
mapping: {},
validation_results: []
};
}
validate() {
// Could add validation for file format, required permissions, etc.
return true;
}
}
/**
* Inventory Audit Workflow
* Performs a complete audit of inventory items, checking quantities, locations, and conditions
*/
class InventoryAuditWorkflow extends BaseWorkflow {
constructor() {
super(
'inventory-audit',
'Inventory Audit',
'Inventory Management',
'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
['b-icon-list-ul'],
'2-4 hours',
8
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Initialize Audit', description: 'Set up audit parameters and scope' },
{ step: 2, name: 'Generate Item List', description: 'Create list of items to audit' },
{ step: 3, name: 'Location Verification', description: 'Verify item locations' },
{ step: 4, name: 'Quantity Count', description: 'Count physical quantities' },
{ step: 5, name: 'Condition Assessment', description: 'Assess item conditions' },
{ step: 6, name: 'Discrepancy Detection', description: 'Identify discrepancies' },
{ step: 7, name: 'Report Generation', description: 'Generate audit report' },
{ step: 8, name: 'Finalize Audit', description: 'Complete and archive audit' }
];
}
validate() {
// Add specific validation logic for inventory audit
return true;
}
}
/**
* Storage Optimization Workflow
* Analyzes and reorganizes storage locations for maximum efficiency and accessibility
*/
class StorageOptimizationWorkflow extends BaseWorkflow {
constructor() {
super(
'storage-optimization',
'Storage Optimization',
'Storage Management',
'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],
'1-2 hours',
5
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Analyze Current Layout', description: 'Assess current storage efficiency' },
{ step: 2, name: 'Identify Optimization Opportunities', description: 'Find areas for improvement' },
{ step: 3, name: 'Plan Reorganization', description: 'Create optimization plan' },
{ step: 4, name: 'Execute Changes', description: 'Implement storage changes' },
{ step: 5, name: 'Validate Results', description: 'Verify optimization results' }
];
}
}
/**
* Maintenance Schedule Workflow
* Creates and executes maintenance schedules for tools and equipment
*/
class MaintenanceScheduleWorkflow extends BaseWorkflow {
constructor() {
super(
'maintenance-schedule',
'Maintenance Schedule',
'Tool Maintenance',
'Create and execute maintenance schedules for tools and equipment.',
['b-icon-tools', 'b-icon-calendar'],
'30 minutes',
4
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Identify Equipment', description: 'Select tools and equipment for maintenance' },
{ step: 2, name: 'Create Schedule', description: 'Define maintenance intervals and tasks' },
{ step: 3, name: 'Assign Responsibilities', description: 'Assign maintenance tasks to users' },
{ step: 4, name: 'Activate Schedule', description: 'Enable automatic maintenance reminders' }
];
}
}
/**
* Expiry Date Check Workflow
* Identifies and handles items approaching or past their expiry dates
*/
class ExpiryCheckWorkflow extends BaseWorkflow {
constructor() {
super(
'expiry-check',
'Expiry Date Check',
'Quality Control',
'Identify and handle items approaching or past their expiry dates.',
['b-icon-clock-history', 'b-icon-exclamation-triangle'],
'45 minutes',
6
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Scan Expiry Dates', description: 'Check all items for expiry information' },
{ step: 2, name: 'Identify Critical Items', description: 'Find expired and soon-to-expire items' },
{ step: 3, name: 'Assess Item Condition', description: 'Evaluate condition of critical items' },
{ step: 4, name: 'Generate Action Plan', description: 'Create disposal or usage recommendations' },
{ step: 5, name: 'Execute Actions', description: 'Implement recommended actions' },
{ step: 6, name: 'Update Records', description: 'Update item statuses and records' }
];
}
getInitialPayload() {
return {
...super.getInitialPayload(),
check_parameters: {
warning_days: 30,
include_no_expiry: false,
categories: []
}
};
}
}
/**
* Data Backup Workflow
* Creates a comprehensive backup of inventory and settings data
*/
class DataBackupWorkflow extends BaseWorkflow {
constructor() {
super(
'backup-restore',
'Data Backup',
'System Maintenance',
'Create a comprehensive backup of your inventory and settings data.',
['b-icon-gear', 'b-icon-download'],
'15 minutes',
3
);
}
getStepDefinitions() {
return [
{ step: 1, name: 'Prepare Backup', description: 'Initialize backup process and verify system' },
{ step: 2, name: 'Export Data', description: 'Export inventory, settings, and user data' },
{ step: 3, name: 'Finalize Backup', description: 'Compress and store backup file' }
];
}
getInitialPayload() {
return {
...super.getInitialPayload(),
backup_options: {
include_inventory: true,
include_settings: true,
include_user_data: true,
include_files: false,
compression: true
}
};
}
}
/**
* Workflow Registry
* Central registry for all available workflow types
*/
class WorkflowRegistry {
constructor() {
this.workflows = new Map();
this.registerDefaultWorkflows();
}
/**
* Register all default workflow types
*/
registerDefaultWorkflows() {
this.register(new FotoFirstImportWorkflow());
this.register(new BulkItemImportWorkflow());
this.register(new InventoryAuditWorkflow());
this.register(new StorageOptimizationWorkflow());
this.register(new MaintenanceScheduleWorkflow());
this.register(new ExpiryCheckWorkflow());
this.register(new DataBackupWorkflow());
}
/**
* Register a workflow type
*/
register(workflow) {
if (!(workflow instanceof BaseWorkflow)) {
throw new Error('Workflow must extend BaseWorkflow');
...ownPayload
}
this.workflows.set(workflow.id, workflow);
}
/**
* Get a workflow by ID
*/
get(id) {
return this.workflows.get(id);
}
/**
* Get all registered workflows
*/
getAll() {
return Array.from(this.workflows.values());
}
/**
* Get workflows by category
*/
getByCategory(category) {
return this.getAll().filter(workflow => workflow.category === category);
}
/**
* Get all unique categories
*/
getCategories() {
return [...new Set(this.getAll().map(workflow => workflow.category))];
}
};
}
// Create and export the default registry instance
export const workflowRegistry = new WorkflowRegistry();
/**
* The backend stores WorkflowInstance.payload as an opaque string - it never
* parses or understands it as JSON. The frontend is fully responsible for
@ -413,7 +114,6 @@ export function serializeWorkflowPayload(workflow) {
if (!workflow || !('payload' in workflow)) return workflow;
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
}
export function deserializeWorkflowPayload(workflow) {
if (!workflow) return workflow;
let payload = {};
@ -424,19 +124,13 @@ export function deserializeWorkflowPayload(workflow) {
}
return {...workflow, payload};
}
// Export individual classes for direct use
export {
BaseWorkflow,
InventoryAuditWorkflow,
StorageOptimizationWorkflow,
FotoFirstImportWorkflow,
MaintenanceScheduleWorkflow,
ExpiryCheckWorkflow,
DataBackupWorkflow,
BulkItemImportWorkflow,
WorkflowRegistry
export default {
getAllWorkflows,
getWorkflow,
getWorkflowComponent,
getWorkflowsByCategory,
getWorkflowCategories,
buildWorkflowApiPayload,
serializeWorkflowPayload,
deserializeWorkflowPayload
};
// Export default registry
export default workflowRegistry;