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

@ -0,0 +1,21 @@
# Generated manually: WorkflowInstance.payload changes from a native JSONField
# to a plain opaque TextField. The frontend now serializes/deserializes the
# JSON itself; the backend just stores whatever string it receives.
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0008_alter_inventoryitem_storage_location'),
]
operations = [
migrations.AlterField(
model_name='workflowinstance',
name='payload',
field=models.TextField(blank=True, default=''),
),
]

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
* Workflow Catalog
*
* All workflow classes should implement this interface:
* 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.
*
* 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
* 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.
*
* // Methods
* validate(): boolean; // Validate if workflow can be started
* getStepDefinitions(): Array; // Get array of step definitions
* getInitialPayload(): Object; // Get initial payload structure
* }
* 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;

512
testdata/generate_testdata.py vendored Normal file
View file

@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Generate realistic test data for the offlinedata export/import functionality.
Produces user-a.key, user-b.key, user-a.zip, user-b.zip for two federated users
who are friends with each other.
"""
import csv
import io
import json
import os
import random
import zipfile
from hashlib import sha256
from nacl.signing import SigningKey
from nacl.encoding import HexEncoder
# Realistic item database with shared_data category/tag references using fully qualified handles
# Format: (name, category_handle, tags_handles, policy, description, qty, properties_list)
# Category handles: origin#category:name (e.g., 'git:base#category:tools')
# Tag handles: origin#tag:name (e.g., 'git:tools#tag:drill')
# Properties format: list of (property_handle, value) tuples
ITEMS_DATABASE = [
# IT/Electronics items
('MacBook Pro 14"', 'git:it#category:pc', 'git:it#tag:laptop,git:it#tag:monitor', 'private', 'Powerful development machine, 16GB RAM, M1 Pro', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '1.7kg')]),
('USB-C Hub', 'git:base#category:hardware', 'git:ee#tag:connector,git:ee#tag:cable', 'lend', '7-in-1 docking station with HDMI, USB3, SD reader', 3, [('git:base#property:power', '65W')]),
('Wireless Mouse', 'git:it#category:pc', 'git:it#tag:mouse', 'private', 'Logitech MX Master 3, rechargeable', 1, [('git:base#property:weight', '110g')]),
('Monitor 4K', 'git:it#category:pc', 'git:it#tag:monitor', 'private', '32" LG UltraFine, factory calibrated', 1, [('git:base#property:area', '32"'), ('git:base#property:power', '90W')]),
('Mechanical Keyboard', 'git:it#category:pc', 'git:it#tag:keyboard', 'lend', 'Keychron K8, hot-swappable, blue switches', 1, [('git:base#property:weight', '400g')]),
('USB Flash Drive', 'git:base#category:hardware', 'git:it#tag:USB', 'private', '256GB Kingston DataTraveler, encrypted', 2, [('git:base#property:memory', '256GB'), ('git:base#property:weight', '50g')]),
('HDMI Cables', 'git:electrical#category:connectors', 'git:it#tag:hdmi,git:it#tag:cable', 'share', 'Assorted 2.0 and 2.1 cables, 6 pack', 1, [('git:base#property:length', '2m')]),
('Power Banks', 'git:base#category:hardware', 'git:ee#tag:battery,git:ee#tag:power supply', 'lend', 'Anker 65W with multiple USB ports', 2, [('git:base#property:power', '65W'), ('git:base#property:weight', '195g')]),
('Webcam 4K', 'git:it#category:pc', 'git:it#tag:webcam', 'private', 'Logitech Brio, excellent for video calls', 1, [('git:base#property:power', '5W')]),
('Microphone', 'git:it#category:pc', 'git:it#tag:microphone', 'private', 'Blue Yeti condenser, noise cancellation', 1, [('git:base#property:power', '2.1W'), ('git:base#property:weight', '508g')]),
# Tools
('Cordless Drill', 'git:tools#category:powertools', 'git:tools#tag:cordless,git:tools#tag:drill', 'lend', 'DeWalt 20V, includes battery and charger', 1, [('git:base#property:voltage', '20V'), ('git:base#property:power', '1500W')]),
('Screwdriver Set', 'git:base#category:tools', 'git:base#tag:screwdriver', 'share', '24-piece precision set, magnetic', 1, [('git:base#property:weight', '1.2kg')]),
('Adjustable Wrench', 'git:base#category:tools', 'git:base#tag:wrench', 'share', 'Chrome plated, 0-24mm range', 3, [('git:base#property:length', '0.24m')]),
('Hammer', 'git:base#category:tools', 'git:base#tag:hammer', 'share', '16oz claw hammer, fiberglass handle', 2, [('git:base#property:weight', '453g')]),
('Level', 'git:base#category:tools', 'git:base#tag:level', 'share', '24" aluminum spirit level', 1, [('git:base#property:length', '0.61m')]),
('Tape Measure', 'git:base#category:tools', 'git:base#tag:tape measure', 'share', '25ft retractable, self-locking', 2, [('git:base#property:length', '7.6m')]),
('Flashlight', 'git:base#category:tools', 'git:base#tag:flashlight', 'lend', 'LED headlamp, 500 lumens, rechargeable', 1, [('git:base#property:power', '10W')]),
('Socket Set', 'git:base#category:tools', 'git:base#tag:wrench', 'share', 'SAE and metric, 70 pieces, impact-rated', 1, [('git:base#property:weight', '2.5kg')]),
('Pliers Set', 'git:base#category:tools', 'git:base#tag:pliers', 'share', 'Needle-nose, slip-joint, locking vise, 4pc', 1, [('git:base#property:weight', '1.8kg')]),
('Pipe Wrench', 'git:base#category:tools', 'git:base#tag:wrench', 'share', '10" adjustable, cast iron', 1, [('git:base#property:length', '0.25m')]),
# Electrical connectors
('Power Cable Type C', 'git:electrical#category:electrical', 'git:electrical#tag:Type C', 'private', 'CEE 7/16 Europlug cable, 3m', 2, [('git:base#property:voltage', '250V'), ('git:base#property:length', '3m')]),
('USB Type-C Adapter', 'git:base#category:hardware', 'git:it#tag:Type C,git:it#tag:USB', 'share', 'USB 3.1 adapter hub', 1, [('git:base#property:power', '100W')]),
('Ethernet Cable Cat6A', 'git:base#category:hardware', 'git:ee#tag:ethernet,git:ee#tag:cable', 'share', 'Cat6A shielded, 100ft spool', 1, [('git:base#property:length', '30m')]),
('HDMI 2.1 Cable', 'git:electrical#category:connectors', 'git:it#tag:hdmi', 'share', '4K60Hz capable, gold-plated', 1, [('git:base#property:length', '2m')]),
# Electronics & Hardware
('Resistor Assortment', 'git:base#category:hardware', 'git:ee#tag:resistor', 'share', '1000pc through-hole variety pack', 1, [('git:base#property:weight', '200g')]),
('LED Pack', 'git:base#category:hardware', 'git:ee#tag:led', 'share', '100pc RGB addressable LEDs', 1, [('git:base#property:power', '5V')]),
('Arduino Uno', 'git:base#category:hardware', 'git:ee#tag:arduino', 'lend', 'ATmega328P-based microcontroller board', 1, [('git:base#property:voltage', '5V'), ('git:base#property:memory', '32KB')]),
('Raspberry Pi 4', 'git:base#category:hardware', 'git:ee#tag:raspberry', 'private', '4GB RAM, Broadcom BCM2711', 1, [('git:base#property:memory', '4GB'), ('git:base#property:power', '10W')]),
('Soldering Iron', 'git:base#category:tools', 'git:ee#tag:soldering', 'share', 'Hakko FX-888D digital station', 1, [('git:base#property:temperature', '250°C'), ('git:base#property:power', '70W')]),
('Solder', 'git:base#category:hardware', 'git:ee#tag:solder', 'share', 'Lead-free, 0.8mm spool', 1, [('git:base#property:weight', '100g')]),
('Breadboard', 'git:base#category:hardware', 'git:ee#tag:connector', 'share', '830 point solderless breadboard', 1, [('git:base#property:weight', '180g')]),
# Fasteners
('M3 Screw Assortment', 'git:screws#category:screws', 'git:screws#tag:m3', 'share', '100pc socket head cap screws', 1, [('git:base#property:diameter', '3mm')]),
('M5 Bolt Pack', 'git:screws#category:screws', 'git:screws#tag:m5', 'share', '50pc hex bolts with nuts', 1, [('git:base#property:diameter', '5mm')]),
('Washer Set', 'git:base#category:hardware', 'git:base#tag:washer', 'share', '200pc stainless steel assortment', 1, [('git:base#property:weight', '300g')]),
# Miscellaneous
('Backpack', 'git:base#category:tools', 'git:base#tag:tool', 'private', 'Waterproof laptop backpack, 30L capacity', 1, [('git:base#property:volume', '30L'), ('git:base#property:weight', '1.2kg')]),
('Headphones', 'git:it#category:pc', 'git:it#tag:headset', 'private', 'Sony WH-1000XM5, noise canceling', 1, [('git:base#property:weight', '250g'), ('git:base#property:frequency', '20Hz')]),
# Extended PC/Laptop items
('Dell XPS 15', 'git:it#category:pc', 'git:it#tag:laptop', 'private', '15.6" FHD display, Intel i7, 32GB RAM', 1, [('git:base#property:memory', '32GB'), ('git:base#property:weight', '2.0kg')]),
('ThinkPad X1 Carbon', 'git:it#category:pc', 'git:it#tag:laptop', 'lend', '14" lightweight ultrabook, business class', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '1.13kg')]),
('iPad Pro 12.9"', 'git:it#category:pc', 'git:it#tag:tablet', 'private', 'M1 chip, 8GB RAM, excellent for design', 1, [('git:base#property:memory', '8GB'), ('git:base#property:area', '12.9"')]),
('Samsung Galaxy Tab S8', 'git:it#category:pc', 'git:it#tag:tablet', 'share', '11" OLED display, great for media', 1, [('git:base#property:memory', '8GB'), ('git:base#property:area', '11"')]),
('Surface Laptop 5', 'git:it#category:pc', 'git:it#tag:laptop', 'private', '13.5" touchscreen, sleek design', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '1.3kg')]),
('ASUS VivoBook', 'git:it#category:pc', 'git:it#tag:laptop', 'lend', '15.6" FHD, AMD Ryzen 5, budget friendly', 1, [('git:base#property:memory', '8GB'), ('git:base#property:weight', '1.8kg')]),
('Razer Blade 15', 'git:it#category:pc', 'git:it#tag:laptop', 'private', 'High-performance gaming laptop, RTX 3060', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '2.0kg')]),
('MacBook Air M2', 'git:it#category:pc', 'git:it#tag:laptop', 'private', '13" Retina display, fanless, 8GB RAM', 1, [('git:base#property:memory', '8GB'), ('git:base#property:weight', '1.24kg')]),
('Lenovo Yoga 9i', 'git:it#category:pc', 'git:it#tag:laptop', 'lend', '13" 2-in-1 convertible, OLED display', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '1.38kg')]),
('HP Spectre x360', 'git:it#category:pc', 'git:it#tag:laptop', 'share', '14" OLED touchscreen, convertible design', 1, [('git:base#property:memory', '16GB'), ('git:base#property:weight', '1.39kg')]),
# Monitors and Displays
('Dell U2723DE', 'git:it#category:pc', 'git:it#tag:monitor', 'share', '27" QHD USB-C dock monitor', 1, [('git:base#property:area', '27"'), ('git:base#property:power', '90W')]),
('LG 27GP850', 'git:it#category:pc', 'git:it#tag:monitor', 'private', '27" 1440p gaming monitor, 165Hz', 1, [('git:base#property:area', '27"'), ('git:base#property:power', '70W')]),
('ASUS PA279CV', 'git:it#category:pc', 'git:it#tag:monitor', 'share', '27" ProArt display, color-accurate', 1, [('git:base#property:area', '27"'), ('git:base#property:power', '65W')]),
('BenQ EW2780U', 'git:it#category:pc', 'git:it#tag:monitor', 'lend', '4K USB-C monitor with docking', 1, [('git:base#property:area', '27"'), ('git:base#property:power', '100W')]),
('Samsung M7', 'git:it#category:pc', 'git:it#tag:monitor', 'share', '32" Smart TV monitor with apps', 1, [('git:base#property:area', '32"'), ('git:base#property:power', '110W')]),
('Ultrawide Curved Monitor', 'git:it#category:pc', 'git:it#tag:monitor', 'lend', '34" 21:9 ultrawide, 100Hz', 1, [('git:base#property:area', '34"'), ('git:base#property:power', '65W')]),
('Portable USB-C Monitor', 'git:it#category:pc', 'git:it#tag:monitor', 'share', '15.6" full HD portable display', 1, [('git:base#property:area', '15.6"'), ('git:base#property:power', '5W')]),
# Keyboards and Input devices
('Keychron K2', 'git:it#category:pc', 'git:it#tag:keyboard', 'private', 'Mechanical, wireless, compact', 1, [('git:base#property:weight', '350g')]),
('Corsair K95 Platinum', 'git:it#category:pc', 'git:it#tag:keyboard', 'private', 'Mechanical RGB, aluminum frame', 1, [('git:base#property:weight', '1.2kg')]),
('Apple Magic Keyboard', 'git:it#category:pc', 'git:it#tag:keyboard', 'private', 'Wireless, rechargeable, sleek design', 1, [('git:base#property:weight', '240g')]),
('Ducky One 2', 'git:it#category:pc', 'git:it#tag:keyboard', 'share', 'Mechanical with PBT keycaps', 1, [('git:base#property:weight', '950g')]),
('Ergonomic Split Keyboard', 'git:it#category:pc', 'git:it#tag:keyboard', 'lend', 'Curved layout, wrist support', 1, [('git:base#property:weight', '1.5kg')]),
('Gaming Macro Keyboard', 'git:it#category:pc', 'git:it#tag:keyboard', 'private', 'Programmable keys, mechanical switches', 1, [('git:base#property:weight', '1.1kg')]),
# Mice and Trackpads
('Logitech G Pro X', 'git:it#category:pc', 'git:it#tag:mouse', 'private', 'Lightweight gaming mouse, 25000 DPI', 1, [('git:base#property:weight', '63g')]),
('Apple Magic Trackpad', 'git:it#category:pc', 'git:it#tag:mouse', 'private', 'Wireless multi-touch surface', 1, [('git:base#property:weight', '231g')]),
('Razer DeathAdder V3', 'git:it#category:pc', 'git:it#tag:mouse', 'share', 'Gaming mouse with HyperScroll wheel', 1, [('git:base#property:weight', '63g')]),
('MX Master 2S', 'git:it#category:pc', 'git:it#tag:mouse', 'share', 'Multi-device, customizable buttons', 1, [('git:base#property:weight', '112g')]),
('Vertical Ergonomic Mouse', 'git:it#category:pc', 'git:it#tag:mouse', 'lend', 'Reduces wrist strain', 1, [('git:base#property:weight', '150g')]),
# Audio Equipment
('Sony WH-CH720N', 'git:it#category:pc', 'git:it#tag:headset', 'lend', 'Wireless headphones with ANC', 1, [('git:base#property:weight', '192g'), ('git:base#property:frequency', '20Hz')]),
('Bose QuietComfort 45', 'git:it#category:pc', 'git:it#tag:headset', 'private', 'Premium ANC headphones', 1, [('git:base#property:weight', '238g'), ('git:base#property:frequency', '20Hz')]),
('Audio-Technica AT2020', 'git:it#category:pc', 'git:it#tag:microphone', 'share', 'Cardioid condenser microphone', 1, [('git:base#property:weight', '155g'), ('git:base#property:power', '1.0W')]),
('Shure SM7B', 'git:it#category:pc', 'git:it#tag:microphone', 'private', 'Broadcasting microphone, dynamic', 1, [('git:base#property:weight', '294g'), ('git:base#property:power', '0.5W')]),
('JBL 104 Studio Monitor', 'git:it#category:pc', 'git:it#tag:speaker', 'share', 'Compact desktop speaker pair', 2, [('git:base#property:weight', '1.2kg'), ('git:base#property:power', '24W')]),
# Cables and Adapters
('Lightning Cable', 'git:electrical#category:connectors', 'git:it#tag:cable', 'share', '2m, MFi certified', 3, [('git:base#property:length', '2m')]),
('DisplayPort Cable', 'git:electrical#category:connectors', 'git:it#tag:cable', 'share', '4K 60Hz capable, 3m', 2, [('git:base#property:length', '3m')]),
('Thunderbolt 3 Cable', 'git:electrical#category:connectors', 'git:ee#tag:cable', 'private', '40Gbps, 2m', 1, [('git:base#property:length', '2m')]),
('VGA to HDMI Adapter', 'git:base#category:hardware', 'git:it#tag:adapter', 'lend', 'Legacy display support', 1, [('git:base#property:power', '0W')]),
('USB to Ethernet Adapter', 'git:base#category:hardware', 'git:ee#tag:ethernet', 'share', 'Gigabit, plug and play', 1, [('git:base#property:power', '5W')]),
# Storage Devices
('Samsung 870 QVO SSD', 'git:base#category:hardware', 'git:it#tag:storage', 'private', '1TB SATA SSD, fast performance', 1, [('git:base#property:memory', '1TB'), ('git:base#property:weight', '60g')]),
('WD Blue NVMe', 'git:base#category:hardware', 'git:it#tag:storage', 'share', '500GB M.2 SSD, reliable', 1, [('git:base#property:memory', '500GB'), ('git:base#property:weight', '10g')]),
('Seagate Barracuda HDD', 'git:base#category:hardware', 'git:it#tag:storage', 'lend', '2TB 3.5" hard drive', 1, [('git:base#property:memory', '2TB'), ('git:base#property:weight', '600g')]),
('Samsung Portable SSD T7', 'git:base#category:hardware', 'git:it#tag:storage', 'share', '1TB USB-C, super fast', 1, [('git:base#property:memory', '1TB'), ('git:base#property:weight', '58g')]),
('OWC Envoy SSD', 'git:base#category:hardware', 'git:it#tag:storage', 'private', 'Thunderbolt 3, 2TB', 1, [('git:base#property:memory', '2TB'), ('git:base#property:weight', '100g')]),
# Networking Equipment
('ASUS RT-AX88U', 'git:base#category:hardware', 'git:ee#tag:router', 'private', 'WiFi 6 router, dual-band', 1, [('git:base#property:power', '35W')]),
('Ubiquiti UniFi AP', 'git:base#category:hardware', 'git:ee#tag:access point', 'share', 'Professional WiFi 6E access point', 1, [('git:base#property:power', '12W')]),
('TP-Link Deco X68', 'git:base#category:hardware', 'git:ee#tag:mesh', 'lend', 'WiFi 6 mesh system, 3-pack', 1, [('git:base#property:power', '24W')]),
('Netgear Nighthawk AXE300', 'git:base#category:hardware', 'git:ee#tag:router', 'share', 'WiFi 6E tri-band router', 1, [('git:base#property:power', '40W')]),
# Power Management
('APC UPS 1500VA', 'git:base#category:hardware', 'git:ee#tag:UPS', 'lend', 'Backup power supply, 10 minutes runtime', 1, [('git:base#property:power', '1500VA'), ('git:base#property:weight', '5.0kg')]),
('Belkin Surge Protector', 'git:base#category:hardware', 'git:ee#tag:power supply', 'share', '6 outlet with USB, 1080J', 1, [('git:base#property:power', '15A')]),
('Anker PowerStrip', 'git:base#category:hardware', 'git:ee#tag:power supply', 'share', 'Smart surge protector, WiFi enabled', 1, [('git:base#property:power', '15A')]),
# Tool Extensions
('Impact Driver', 'git:tools#category:powertools', 'git:tools#tag:cordless', 'lend', '20V, compact design', 1, [('git:base#property:voltage', '20V'), ('git:base#property:power', '1200W')]),
('Circular Saw', 'git:tools#category:powertools', 'git:tools#tag:saw', 'share', 'DeWalt 20V cordless, 7.25"', 1, [('git:base#property:voltage', '20V'), ('git:base#property:power', '800W')]),
('Random Orbital Sander', 'git:tools#category:powertools', 'git:tools#tag:sander', 'share', '5" dust collection', 1, [('git:base#property:power', '400W')]),
('Reciprocating Saw', 'git:tools#category:powertools', 'git:tools#tag:saw', 'lend', 'Variable speed, tool-free blade change', 1, [('git:base#property:power', '1100W')]),
('Jigsaw', 'git:tools#category:powertools', 'git:tools#tag:saw', 'share', 'Orbital motion, LED light', 1, [('git:base#property:power', '600W')]),
('Angle Grinder', 'git:tools#category:powertools', 'git:tools#tag:grinder', 'private', '4.5" with safety features', 1, [('git:base#property:power', '1200W')]),
('Heat Gun', 'git:tools#category:powertools', 'git:tools#tag:heat', 'share', '2 temperature settings', 1, [('git:base#property:power', '1800W')]),
('Belt Sander', 'git:tools#category:powertools', 'git:tools#tag:sander', 'lend', '3"x21" powerful sanding', 1, [('git:base#property:power', '800W')]),
('Table Saw', 'git:tools#category:powertools', 'git:tools#tag:saw', 'lend', '10" blade, 15A motor', 1, [('git:base#property:power', '1500W')]),
('Miter Saw', 'git:tools#category:powertools', 'git:tools#tag:saw', 'share', 'Compound sliding, 12"', 1, [('git:base#property:power', '1500W')]),
# Hand Tool Extensions
('Combination Square', 'git:base#category:tools', 'git:base#tag:measuring', 'share', '12" stainless steel', 1, [('git:base#property:length', '0.3m')]),
('Speed Square', 'git:base#category:tools', 'git:base#tag:measuring', 'share', '7" aluminum', 1, [('git:base#property:weight', '150g')]),
('Torpedo Level', 'git:base#category:tools', 'git:base#tag:level', 'share', '9" compact level', 1, [('git:base#property:length', '0.23m')]),
('Chisel Set', 'git:base#category:tools', 'git:base#tag:chisel', 'lend', '4-piece woodworking chisels', 1, [('git:base#property:weight', '800g')]),
('Nail Punch', 'git:base#category:tools', 'git:base#tag:punch', 'share', 'Set of 3, various sizes', 1, [('git:base#property:weight', '200g')]),
('Pry Bar', 'git:base#category:tools', 'git:base#tag:pry', 'share', '18" steel, multi-purpose', 1, [('git:base#property:length', '0.45m')]),
('Hex Key Set', 'git:base#category:tools', 'git:base#tag:hex', 'share', '25-piece, SAE and metric', 1, [('git:base#property:weight', '400g')]),
('Torque Wrench', 'git:base#category:tools', 'git:base#tag:wrench', 'private', '1/2" drive, 10-150 Nm', 1, [('git:base#property:length', '0.5m')]),
('C-Clamp', 'git:base#category:tools', 'git:base#tag:clamp', 'share', '4" capacity, pack of 4', 1, [('git:base#property:weight', '2.0kg')]),
('Bar Clamp', 'git:base#category:tools', 'git:base#tag:clamp', 'lend', '48" quick-grip style', 1, [('git:base#property:length', '1.2m')]),
# Electrical components continuation
('Capacitor Assortment', 'git:base#category:hardware', 'git:ee#tag:capacitor', 'share', '500pc various values', 1, [('git:base#property:weight', '250g')]),
('Diode Pack', 'git:base#category:hardware', 'git:ee#tag:diode', 'share', '200pc rectifier and signal diodes', 1, [('git:base#property:weight', '100g')]),
('Transistor Assortment', 'git:base#category:hardware', 'git:ee#tag:transistor', 'share', '100pc mixed types', 1, [('git:base#property:weight', '150g')]),
('IC Assortment Pack', 'git:base#category:hardware', 'git:ee#tag:ic', 'share', 'Common logic and op-amps', 1, [('git:base#property:weight', '200g')]),
('Jumper Wire Kit', 'git:base#category:hardware', 'git:ee#tag:cable', 'share', '60pc breadboard jumpers', 1, [('git:base#property:weight', '50g')]),
('PCB Prototyping Board', 'git:base#category:hardware', 'git:ee#tag:pcb', 'share', 'Copper clad universal board', 1, [('git:base#property:weight', '300g')]),
('Heat Shrink Tubing', 'git:base#category:hardware', 'git:ee#tag:insulation', 'share', 'Multi-size assortment', 1, [('git:base#property:weight', '100g')]),
('Flux Pen', 'git:base#category:hardware', 'git:ee#tag:solder', 'share', 'Liquid flux for soldering', 1, [('git:base#property:weight', '50g')]),
('Desoldering Braid', 'git:base#category:hardware', 'git:ee#tag:solder', 'share', 'Copper braid wick', 1, [('git:base#property:weight', '25g')]),
('Solder Sucker', 'git:base#category:hardware', 'git:ee#tag:solder', 'share', 'Manual desoldering pump', 1, [('git:base#property:weight', '50g')]),
# Additional fasteners
('Bolt Assortment', 'git:screws#category:screws', 'git:screws#tag:bolt', 'share', 'Various sizes and lengths', 1, [('git:base#property:weight', '2.0kg')]),
('Nut Assortment', 'git:screws#category:screws', 'git:screws#tag:nut', 'share', 'Metric and SAE sizes', 1, [('git:base#property:weight', '1.5kg')]),
('Anchor Assortment', 'git:screws#category:screws', 'git:screws#tag:anchor', 'share', 'Drywall and cavity anchors', 1, [('git:base#property:weight', '500g')]),
('Brad Nails', 'git:screws#category:screws', 'git:screws#tag:nail', 'share', '16 gauge, 500 pack', 1, [('git:base#property:weight', '200g')]),
('Finishing Nails', 'git:screws#category:screws', 'git:screws#tag:nail', 'share', 'Various sizes, 2 lb box', 1, [('git:base#property:weight', '900g')]),
# Storage and Organization
('Tool Box', 'git:base#category:tools', 'git:base#tag:storage', 'share', 'Metal with compartments', 1, [('git:base#property:volume', '30L'), ('git:base#property:weight', '2.5kg')]),
('Part Organizer', 'git:base#category:tools', 'git:base#tag:storage', 'share', 'Plastic drawer unit, 39 drawers', 1, [('git:base#property:volume', '50L'), ('git:base#property:weight', '3.0kg')]),
('Wall Mount Pegboard', 'git:base#category:tools', 'git:base#tag:storage', 'lend', '4ftx2ft with hooks and baskets', 1, [('git:base#property:weight', '5.0kg')]),
('Cable Organizer', 'git:base#category:hardware', 'git:it#tag:cable', 'share', 'Silicone clips and ties', 1, [('git:base#property:weight', '200g')]),
('Label Maker', 'git:base#category:hardware', 'git:it#tag:office', 'share', 'Handheld, easy to use', 1, [('git:base#property:weight', '150g')]),
# Additional Miscellaneous
('Work Gloves', 'git:base#category:tools', 'git:base#tag:safety', 'share', 'Leather palm, pack of 3', 1, [('git:base#property:weight', '300g')]),
('Safety Glasses', 'git:base#category:tools', 'git:base#tag:safety', 'share', 'Anti-scratch lens, 5 pack', 1, [('git:base#property:weight', '100g')]),
('Dust Mask', 'git:base#category:tools', 'git:base#tag:safety', 'share', 'N95 particulate, 50 pack', 1, [('git:base#property:weight', '200g')]),
('First Aid Kit', 'git:base#category:tools', 'git:base#tag:safety', 'share', 'Compact workspace kit', 1, [('git:base#property:weight', '500g')]),
('Desk Lamp', 'git:it#category:pc', 'git:it#tag:office', 'lend', 'LED, adjustable, USB powered', 1, [('git:base#property:power', '8W')]),
('Monitor Stand', 'git:it#category:pc', 'git:it#tag:office', 'share', 'Adjustable height, storage shelf', 1, [('git:base#property:weight', '2.0kg')]),
('Keyboard Wrist Rest', 'git:it#category:pc', 'git:it#tag:office', 'private', 'Memory foam, ergonomic', 1, [('git:base#property:weight', '300g')]),
('Mouse Pad', 'git:it#category:pc', 'git:it#tag:office', 'share', 'Large XL size with wrist support', 1, [('git:base#property:weight', '400g')]),
('Desk Organizer', 'git:it#category:pc', 'git:it#tag:office', 'private', 'Multi-compartment caddy', 1, [('git:base#property:weight', '600g')]),
('USB Hub Powered', 'git:base#category:hardware', 'git:it#tag:USB', 'share', '7-port with individual switches', 1, [('git:base#property:power', '60W')]),
]
LOCATIONS_DATABASE = [
('Home Office Desk', 'Primary workspace for development'),
('Study Cabinet', 'Vertical storage with shelving'),
('Tool Bench', 'Workshop storage for hardware'),
('Electronics Shelf', 'Electronics and components storage'),
('Bedroom Closet', 'Personal items and backups'),
('Garage Workbench', 'Large tools and equipment'),
('Storage Room', 'Archive and backup storage'),
('Component Drawer', 'Small electronics and adapters'),
('Utility Closet', 'Household maintenance supplies'),
]
def load_shared_data():
"""Load shared_data JSON files and return available tags, categories, and properties."""
shared_data_dir = os.path.join(os.path.dirname(__file__), '..', 'backend', 'shared_data')
all_tags = {} # Maps fully qualified name to tag info
all_categories = {} # Maps fully qualified name to category info
all_properties = {} # Maps fully qualified name to property info
for filename in os.listdir(shared_data_dir):
if filename.endswith('.json'):
filepath = os.path.join(shared_data_dir, filename)
try:
with open(filepath, 'r') as f:
data = json.load(f)
if 'tags' in data:
for tag in data['tags']:
name = tag.get('name', '')
category = tag.get('category', '')
fq_name = f"{category}/{name}" if category else name
all_tags[fq_name] = tag
if 'categories' in data:
for cat in data['categories']:
name = cat.get('name', '')
parent = cat.get('parent', '')
fq_name = f"{parent}/{name}" if parent else name
all_categories[fq_name] = cat
if 'properties' in data:
for prop in data['properties']:
name = prop.get('name', '')
fq_name = f"git:base#property:{name}"
all_properties[fq_name] = prop
except Exception as e:
print(f"Warning: Could not load {filepath}: {e}")
return all_tags, all_categories, all_properties
def generate_keypair():
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
signing_key = SigningKey.generate()
private_hex = signing_key.encode(encoder=HexEncoder).decode('utf-8')
public_hex = signing_key.verify_key.encode(encoder=HexEncoder).decode('utf-8')
return private_hex, public_hex
def generate_locations_csv(count):
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
locations.extend([
(f'Storage {chr(65 + i)}', f'General storage area {i+1}')
for i in range(max(0, count - len(LOCATIONS_DATABASE)))
])
rows = []
location_id_map = {} # Maps location name to its ID
# First pass: create top-level locations
parent_locations = [locations[0]] if locations else []
for i, (name, description) in enumerate(parent_locations, 1):
location_id_map[name] = str(i)
rows.append({
'id': str(i),
'name': name,
'description': description,
'category': '',
'parent': '',
'path': name,
})
# Second pass: create remaining locations, some as children
current_id = len(rows) + 1
for i, (name, description) in enumerate(locations[1:], 1):
parent_name = ''
parent_id = ''
path = name
# Make every other location a child of a random parent
if i % 2 == 0 and parent_locations:
parent_name = random.choice([p[0] for p in parent_locations])
parent_id = location_id_map[parent_name]
path = f"{parent_name}/{name}"
rows.append({
'id': str(current_id),
'name': name,
'description': description,
'category': '',
'parent': parent_id,
'path': path,
})
location_id_map[name] = str(current_id)
current_id += 1
output = io.StringIO()
fieldnames = ['id', 'name', 'description', 'category', 'parent', 'path']
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return output.getvalue().encode('utf-8'), location_id_map
def generate_friends_csv(friend_username, friend_domain, friend_public_key):
"""Generate a friends.csv with one friend entry."""
rows = [
{'username': friend_username, 'domain': friend_domain,
'handle': f'{friend_username}@{friend_domain}', 'public_key': friend_public_key}
]
output = io.StringIO()
fieldnames = ['username', 'domain', 'handle', 'public_key']
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return output.getvalue().encode('utf-8')
def _quote_value_if_needed(value):
"""Mirror `toolshed.offlinedata._quote_value_if_needed()`: wrap a value in double quotes
(CSV-style, doubling any embedded quotes) if it contains a comma or a quote character, so it
round-trips correctly through the comma-separated "handle=value" properties cell.
"""
if any(ch in value for ch in ',"'):
return '"' + value.replace('"', '""') + '"'
return value
def generate_inventory_csv(count, num_locations, location_id_map, item_range=None):
"""Generate a realistic inventory.csv with varied items using shared_data references and properties.
Args:
count: Number of items to generate
num_locations: Number of locations
location_id_map: Map of location names to IDs
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
"""
# Get location names from the map
location_names = list(location_id_map.keys())
# Use partition if specified, otherwise use all items
if item_range is not None:
start_idx, end_idx = item_range
available_items = ITEMS_DATABASE[start_idx:end_idx + 1]
else:
available_items = ITEMS_DATABASE
# Ensure we have enough items by repeating the database
available_items = available_items * ((count // len(available_items)) + 1)
selected_items = random.sample(available_items, min(count, len(available_items)))
rows = []
for i, item_tuple in enumerate(selected_items, 1):
# Handle both old format (6 elements) and new format (7 elements with properties)
if len(item_tuple) == 7:
name, category_handle, tags_str, policy, description, qty, properties_list = item_tuple
else:
name, category_handle, tags_str, policy, description, qty = item_tuple
properties_list = []
location = random.choice(location_names) if location_names else ''
tags_for_item = [t.strip() for t in tags_str.split(',')]
# Format properties as a comma-separated "handle=value" list, matching
# `toolshed.offlinedata._encode_properties_cell()` (quoting values that contain a
# comma or a quote character so they survive the round trip).
properties_str = ', '.join(
f"{prop_handle}={_quote_value_if_needed(value)}" for prop_handle, value in properties_list)
rows.append({
'id': str(i),
'name': name,
'description': description,
'category': category_handle,
'availability_policy': policy,
'owned_quantity': str(qty),
'storage_location': location,
'tags': ', '.join(tags_for_item),
'properties': properties_str,
'files': '',
'created_at': '2026-08-01T00:00:00+00:00',
})
output = io.StringIO()
fieldnames = ['id', 'name', 'description', 'category', 'availability_policy',
'owned_quantity', 'storage_location', 'tags', 'properties', 'files', 'created_at']
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return output.getvalue().encode('utf-8')
def generate_sample_files():
"""Generate sample files for the 'files/' folder in the zip."""
files = {}
sample_contents = [
b'Product specifications and manual for electronics.\n',
b'Workshop notes and maintenance log.\n',
b'Purchase receipt and warranty information.\n',
]
for i, content in enumerate(sample_contents):
content_hash = sha256(content).hexdigest()
arcname = f'files/sample_{i}.txt'
files[arcname] = content
return files
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
num_locations=10, num_items=150, item_range=None):
"""Generate a zip file (bytes) like user_data() produces.
Args:
username: Username for this user
domain: Domain for this user
friend_username: Friend's username
friend_domain: Friend's domain
friend_public_key: Friend's public key
num_locations: Number of locations to generate
num_items: Number of items to generate
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
"""
num_locations = max(5, min(20, num_locations))
num_items = max(100, min(200, num_items))
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED) as zip_file:
locations_csv, location_id_map = generate_locations_csv(num_locations)
zip_file.writestr('locations.csv', locations_csv)
friends_csv = generate_friends_csv(friend_username, friend_domain, friend_public_key)
zip_file.writestr('friends.csv', friends_csv)
inventory_csv = generate_inventory_csv(num_items, num_locations, location_id_map, item_range)
zip_file.writestr('inventory.csv', inventory_csv)
sample_files = generate_sample_files()
for arcname, content in sample_files.items():
zip_file.writestr(arcname, content)
return zip_buffer.getvalue()
def main():
"""Generate and write test data files."""
script_dir = os.path.dirname(os.path.abspath(__file__))
print("Loading shared_data references...")
all_tags, all_categories, all_properties = load_shared_data()
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
print("\nGenerating keypairs...")
user_a_private, user_a_public = generate_keypair()
user_b_private, user_b_public = generate_keypair()
print(f" user-a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
print(f" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
print("\nWriting key files...")
with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
f.write(user_a_private)
with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
f.write(user_b_private)
print(" ✓ user-a.key")
print(" ✓ user-b.key")
print("\nGenerating realistic export zips with partitioned items...")
# Partition the ITEMS_DATABASE randomly
partition_point = int(len(ITEMS_DATABASE)/4) + random.randint(0, int(len(ITEMS_DATABASE)/2))
user_a_item_range = (0, partition_point)
user_b_item_range = (partition_point + 1, len(ITEMS_DATABASE) - 1)
print(f" Database partitioned at index {partition_point}")
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
user_a_zip = generate_export_zip(
'user-a', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
num_locations=12, num_items=175, item_range=user_a_item_range
)
user_b_zip = generate_export_zip(
'user-b', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
num_locations=8, num_items=140, item_range=user_b_item_range
)
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:
f.write(user_a_zip)
with open(os.path.join(script_dir, 'user-b.zip'), 'wb') as f:
f.write(user_b_zip)
print(" ✓ user-a.zip")
print(" ✓ user-b.zip")
print("\n✓ Test data generation complete!")
print(f"Files written to: {script_dir}/")
if __name__ == '__main__':
main()

2
testdata/user-a.key vendored
View file

@ -1 +1 @@
2a7ddeb75181afedbc755924db9f1d527278375ff562858c93a502ab783a6815
795e884532881938a1b03e37731443f4d1974327dafd5d508e0cb00cb0b161b6

BIN
testdata/user-a.zip vendored

Binary file not shown.

2
testdata/user-b.key vendored
View file

@ -1 +1 @@
5b0f8e19806c5ab87eced9a0d08855429e1b2be957b760b46fbbef2309eccd43
d011fedc3ba733baef6aa7550bbee47db66c2ad3dbfb0f6dcc14c984866df5eb

BIN
testdata/user-b.zip vendored

Binary file not shown.