stash
This commit is contained in:
parent
cfcc2c15d3
commit
4f2fe011c0
28 changed files with 3499 additions and 2922 deletions
|
|
@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
|
|
@ -1,147 +1,123 @@
|
||||||
# Workflow Step Component Dispatching System
|
# Workflow System
|
||||||
|
|
||||||
## Overview
|
## 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
|
## 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:**
|
- **Metadata**: a static `meta: { id, name, category, description, icons,
|
||||||
- `getStepComponent(workflowType, step)` - Retrieves a step component
|
estimatedDuration, stepDefinitions, getInitialPayload() }` option on the
|
||||||
- `registerStepComponent(workflowType, step, component)` - Registers new components
|
component's options object, right alongside `name`/`components`/`props`.
|
||||||
- `hasStepComponent(workflowType, step)` - Checks component existence
|
- **UI**: the same component implements every step internally, branching on
|
||||||
- `getWorkflowComponents(workflowType)` - Gets all components for a workflow
|
the `step` prop (e.g. `v-if="step === '1'"`, `v-else-if="step === '2'"`, ...).
|
||||||
- `getRegisteredWorkflowTypes()` - Lists all registered workflow types
|
|
||||||
|
|
||||||
### 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
|
A single flat array assembled from:
|
||||||
- **FotoFirstStep1.vue** - Photo capture/upload with camera and file upload support
|
- `implementedWorkflows` - workflows with a real component, built by
|
||||||
- **FotoFirstStep2.vue** - Image processing with compression and optimization
|
importing the component and reading its static `.meta` property:
|
||||||
- **FotoFirstStep3.vue** - Item details entry with form-based data collection
|
`{ ...Component.meta, component: Component }`
|
||||||
- **FotoFirstStep4.vue** - Import completion with summary and finalization
|
- `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
|
Exposed functions:
|
||||||
- **BulkImportStep1.vue** - File upload with CSV/Excel support and column mapping
|
- `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
|
### 3. Usage in the views
|
||||||
|
|
||||||
The `stepComponent` computed property now uses the registry:
|
|
||||||
|
|
||||||
|
`WorkflowDetail.vue`:
|
||||||
```javascript
|
```javascript
|
||||||
stepComponent() {
|
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
||||||
|
|
||||||
|
workflowDefinition() {
|
||||||
// `name` on the WorkflowInstance doubles as the workflow type identifier
|
// `name` on the WorkflowInstance doubles as the workflow type identifier
|
||||||
const workflowType = this.workflowInstance?.name;
|
return getWorkflow(this.workflowInstance?.name);
|
||||||
if (workflowType && this.currentStep) {
|
},
|
||||||
return getStepComponent(workflowType, this.currentStep);
|
workflowComponent() {
|
||||||
}
|
return getWorkflowComponent(this.workflowInstance?.name);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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**
|
`Workflows.vue` uses `getAllWorkflows()` for the catalog grid and
|
||||||
- Components connect to Vuex store's `active_workflows` state
|
`buildWorkflowApiPayload(workflow)` when starting a new instance.
|
||||||
- `loadWorkflowInstance()` fetches and finds specific workflow instances
|
|
||||||
- State determines which workflow type and step are active
|
|
||||||
|
|
||||||
### 2. **Dynamic Component Resolution**
|
### 4. Component contract
|
||||||
- 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
|
|
||||||
|
|
||||||
### 3. **Component Communication**
|
Every workflow component receives props `workflowInstance`, `step`, `payload`
|
||||||
- Step components receive props: `workflowInstance`, `step`, `payload`
|
and emits `update`, `next`, `prev`, `complete`. `WorkflowDetail.vue` handles
|
||||||
- Components emit events: `@update`, `@next`, `@prev`, `@complete`
|
persisting payload updates (`handleStepUpdate`) and step navigation
|
||||||
- Parent WorkflowDetail handles state updates and navigation
|
(`handleNextStep` / `handlePrevStep` / `completeWorkflow`), independent of
|
||||||
|
which workflow is active.
|
||||||
|
|
||||||
### 4. **Workflow Active Classes**
|
### 5. Workflow progress sidebar
|
||||||
- `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
|
|
||||||
|
|
||||||
## Component Features
|
Step metadata (name/description, total step count) comes from
|
||||||
|
`workflowDefinition.stepDefinitions` (the catalog entry, i.e. `Component.meta.stepDefinitions`),
|
||||||
### FotoFirstStep1 (Photo Capture)
|
used to render the progress sidebar and step list regardless of whether a
|
||||||
- **Camera Integration**: Uses `navigator.mediaDevices.getUserMedia()`
|
custom UI exists yet. `isStepCurrent(stepNumber)` / `isStepCompleted(stepNumber)` /
|
||||||
- **File Upload**: Drag-and-drop and file selection
|
`getStepClass(stepNumber)` in `WorkflowDetail.vue` drive the sidebar styling.
|
||||||
- **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
|
|
||||||
|
|
||||||
## Extensibility
|
## Extensibility
|
||||||
|
|
||||||
### Adding New Workflow Types
|
### Adding UI for a planned workflow
|
||||||
1. Create step components in `/components/workflow/steps/`
|
1. Create `/components/workflow/workflows/MyWorkflow.vue`:
|
||||||
2. Import components in `ComponentRegistry.js`
|
- Add a `meta: {...}` option to the exported component (copy the matching
|
||||||
3. Add mappings to `componentRegistry` object
|
entry out of `plannedWorkflows` in `workflows.js` and delete it there)
|
||||||
4. Define workflow in `workflows.js`
|
- 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
|
### Adding a brand new workflow type
|
||||||
1. Create component: `WorkflowTypeStepN.vue`
|
1. Create the `.vue` file as above with a unique `id`
|
||||||
2. Import and register in ComponentRegistry
|
2. Add it to `implementedWorkflows` in `workflows.js` (if it has UI) or add a
|
||||||
3. Update workflow step definitions
|
plain metadata object to `plannedWorkflows` (if it doesn't, yet)
|
||||||
4. Component automatically dispatched when step is reached
|
|
||||||
|
### 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
|
## Benefits
|
||||||
|
|
||||||
1. **Modularity**: Each step is an independent, reusable component
|
1. **Single source of truth**: metadata and UI for a workflow live in one file
|
||||||
2. **Flexibility**: Easy to create workflow-specific UI experiences
|
2. **No indirection**: one array, no separate component registry to keep in sync
|
||||||
3. **Maintainability**: Clear separation of concerns
|
3. **Cohesion**: all state and logic for a workflow's steps lives together
|
||||||
4. **Extensibility**: Simple process to add new workflows and steps
|
4. **Extensibility**: new workflows are a single new file + one array entry
|
||||||
5. **Type Safety**: Registry provides centralized component management
|
5. **Consistency**: standardized props (`workflowInstance`, `step`, `payload`)
|
||||||
6. **Performance**: Components only loaded when needed
|
and events (`update`, `next`, `prev`, `complete`) across all workflow components
|
||||||
7. **Consistency**: Standardized props and events across all step 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ import Friends from '@/views/Friends.vue';
|
||||||
import Inventory from '@/views/Inventory.vue';
|
import Inventory from '@/views/Inventory.vue';
|
||||||
import Search from '@/views/Search.vue';
|
import Search from '@/views/Search.vue';
|
||||||
import InventoryDetail from '@/views/InventoryDetail.vue';
|
import InventoryDetail from '@/views/InventoryDetail.vue';
|
||||||
|
import InventoryDetailForeign from '@/views/InventoryDetailForeign.vue';
|
||||||
import InventoryNew from '@/views/InventoryNew.vue';
|
import InventoryNew from '@/views/InventoryNew.vue';
|
||||||
import InventoryEdit from '@/views/InventoryEdit.vue';
|
import InventoryEdit from '@/views/InventoryEdit.vue';
|
||||||
import StorageLocation from '@/views/StorageLocation.vue';
|
import StorageLocation from '@/views/StorageLocation.vue';
|
||||||
|
|
@ -45,6 +46,11 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
component: InventoryEdit,
|
component: InventoryEdit,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: 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: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||||
path: '/friends',
|
path: '/friends',
|
||||||
component: Friends,
|
component: Friends,
|
||||||
|
|
|
||||||
102
frontend/src/views/InventoryDetailForeign.vue
Normal file
102
frontend/src/views/InventoryDetailForeign.vue
Normal 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>
|
||||||
|
|
@ -27,20 +27,13 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in search_results" :key="item.id">
|
<tr v-for="item in search_results" :key="item.id">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
|
<router-link :to="`/inventory/${item.handle}`">{{ item.name }}</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<user-name-tag :user="item"/>
|
<user-name-tag :user="item"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||||
<td class="table-action">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -56,7 +49,7 @@
|
||||||
class="card-img-top img-preview"/>
|
class="card-img-top img-preview"/>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-0">
|
<h5 class="card-title mb-0">
|
||||||
<router-link :to="`/inventory/${item.id}`">
|
<router-link :to="`/inventory/${item.handle}`">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</router-link></h5>
|
</router-link></h5>
|
||||||
<div class="card-text text-black-50">
|
<div class="card-text text-black-50">
|
||||||
|
|
@ -78,7 +71,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapActions} from 'vuex';
|
import {mapActions, mapState} from 'vuex';
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import SearchBox from "@/components/SearchBox.vue";
|
import SearchBox from "@/components/SearchBox.vue";
|
||||||
|
|
@ -114,10 +107,13 @@ export default {
|
||||||
},
|
},
|
||||||
loadResults() {
|
loadResults() {
|
||||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
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: {
|
watch: {
|
||||||
query: {
|
query: {
|
||||||
immediate: false,
|
immediate: false,
|
||||||
|
|
|
||||||
|
|
@ -142,14 +142,15 @@
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<!-- Step-specific content based on workflow type and current step -->
|
<!-- Step-specific content based on workflow type and current step -->
|
||||||
<component
|
<component
|
||||||
v-if="stepComponent"
|
v-if="workflowComponent"
|
||||||
:is="stepComponent"
|
:is="workflowComponent"
|
||||||
:workflow-instance="workflowInstance"
|
:workflow-instance="workflowInstance"
|
||||||
:step="currentStep"
|
:step="currentStep"
|
||||||
:payload="workflowInstance.payload"
|
:payload="workflowInstance.payload"
|
||||||
@update="handleStepUpdate"
|
@update="handleStepUpdate"
|
||||||
@next="handleNextStep"
|
@next="handleNextStep"
|
||||||
@prev="handlePrevStep"
|
@prev="handlePrevStep"
|
||||||
|
@complete="completeWorkflow"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Default step content if no specific component -->
|
<!-- Default step content if no specific component -->
|
||||||
|
|
@ -201,8 +202,7 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import { mapState, mapActions } from 'vuex';
|
import { mapState, mapActions } from 'vuex';
|
||||||
import { workflowRegistry } from '@/workflows.js';
|
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
||||||
import { getStepComponent, hasStepComponent } from '@/components/workflow/ComponentRegistry.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'WorkflowDetail',
|
name: 'WorkflowDetail',
|
||||||
|
|
@ -238,11 +238,11 @@ export default {
|
||||||
workflowDefinition() {
|
workflowDefinition() {
|
||||||
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
||||||
if (!this.workflowInstance?.name) return null;
|
if (!this.workflowInstance?.name) return null;
|
||||||
return workflowRegistry.get(this.workflowInstance.name);
|
return getWorkflow(this.workflowInstance.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
stepDefinitions() {
|
stepDefinitions() {
|
||||||
return this.workflowDefinition?.getStepDefinitions() || [];
|
return this.workflowDefinition?.stepDefinitions || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
totalSteps() {
|
totalSteps() {
|
||||||
|
|
@ -257,13 +257,12 @@ export default {
|
||||||
return this.workflowInstance?.state === 'running';
|
return this.workflowInstance?.state === 'running';
|
||||||
},
|
},
|
||||||
|
|
||||||
stepComponent() {
|
workflowComponent() {
|
||||||
// Return step-specific component if it exists using the component registry
|
// 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;
|
const workflowType = this.workflowInstance?.name;
|
||||||
if (workflowType && this.currentStep) {
|
return workflowType ? getWorkflowComponent(workflowType) : null;
|
||||||
return getStepComponent(workflowType, this.currentStep);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
currentStepIndex() {
|
currentStepIndex() {
|
||||||
|
|
@ -282,8 +281,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
hasCustomComponent() {
|
hasCustomComponent() {
|
||||||
// Check if the current step has a custom component defined
|
// Check if the current workflow has a custom component defined
|
||||||
return this.stepComponent !== null;
|
return this.workflowComponent !== null;
|
||||||
},
|
},
|
||||||
|
|
||||||
componentStatusClass() {
|
componentStatusClass() {
|
||||||
|
|
@ -463,12 +462,6 @@ export default {
|
||||||
formatDateTime(dateString) {
|
formatDateTime(dateString) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return 'N/A';
|
||||||
return new Date(dateString).toLocaleString();
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import { mapState, mapActions } from 'vuex';
|
import { mapState, mapActions } from 'vuex';
|
||||||
import { workflowRegistry } from '@/workflows.js';
|
import { getAllWorkflows, getWorkflow, buildWorkflowApiPayload } from '@/workflows.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Workflows',
|
name: 'Workflows',
|
||||||
|
|
@ -148,7 +148,7 @@ export default {
|
||||||
return this.active_workflows;
|
return this.active_workflows;
|
||||||
},
|
},
|
||||||
availableWorkflows() {
|
availableWorkflows() {
|
||||||
return workflowRegistry.getAll();
|
return getAllWorkflows();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
@ -221,22 +221,22 @@ export default {
|
||||||
},
|
},
|
||||||
getWorkflowDisplayName(workflow) {
|
getWorkflowDisplayName(workflow) {
|
||||||
// `name` doubles as the workflow type identifier (e.g. 'import-items').
|
// `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) {
|
async startWorkflow(workflow) {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
// Use the workflow's toApiFormat method to get properly formatted data
|
// Build the properly formatted data to start this workflow
|
||||||
const workflowData = workflow.toApiFormat();
|
const workflowData = buildWorkflowApiPayload(workflow);
|
||||||
|
|
||||||
const newWorkflow = await this.createWorkflow(workflowData);
|
const newWorkflow = await this.createWorkflow(workflowData);
|
||||||
console.log('Workflow started successfully:', newWorkflow);
|
console.log('Workflow started successfully:', newWorkflow);
|
||||||
|
|
||||||
// Immediately navigate to the workflow detail view
|
// Immediately navigate to the workflow detail view
|
||||||
// Get the first step from the workflow definition
|
// 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({
|
this.$router.push({
|
||||||
name: 'workflow-detail',
|
name: 'workflow-detail',
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -258,7 +258,7 @@ export default {
|
||||||
// Use the workflow's current step if available, otherwise use the first step
|
// Use the workflow's current step if available, otherwise use the first step
|
||||||
const currentStep = workflow.current_step ||
|
const currentStep = workflow.current_step ||
|
||||||
workflow.payload?.current_step ||
|
workflow.payload?.current_step ||
|
||||||
workflow.getStepDefinitions?.()?.[0]?.step ||
|
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
|
||||||
"initial";
|
"initial";
|
||||||
|
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
* interface IWorkflow {
|
* it has and what they're called, what its initial payload looks like, and
|
||||||
* id: string; // Unique identifier for the workflow type
|
* which Vue component renders it.
|
||||||
* name: string; // Display name for the workflow
|
*
|
||||||
* category: string; // Category grouping (e.g., 'Data Management', 'Inventory Management')
|
* Each workflow has a fully co-located component + metadata as a static
|
||||||
* description: string; // Detailed description of what the workflow does
|
* `meta` option on the component (`Component.meta`, right next to
|
||||||
* icons: Array<string>; // Array of Bootstrap icon component names
|
* `name`/`props`/etc.) in `@/components/workflow/workflows/*.vue` - this
|
||||||
* estimatedDuration: string; // Human-readable duration estimate
|
* file simply imports those components and reads `.meta` off of them to
|
||||||
* steps: number; // Total number of steps in the workflow
|
* build the catalog below.
|
||||||
*
|
*
|
||||||
* // Methods
|
* This replaces the previous design of a parallel `BaseWorkflow` class
|
||||||
* validate(): boolean; // Validate if workflow can be started
|
* hierarchy (metadata) plus a separate per-step `ComponentRegistry.js`
|
||||||
* getStepDefinitions(): Array; // Get array of step definitions
|
* (components) - both concerns now live in one flat array with each
|
||||||
* getInitialPayload(): Object; // Get initial payload structure
|
* 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 {
|
const implementedWorkflows = [
|
||||||
constructor(id, name, category, description, icons, estimatedDuration, steps) {
|
{ ...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow },
|
||||||
this.id = id;
|
{ ...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow },
|
||||||
this.name = name;
|
{ ...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow },
|
||||||
this.category = category;
|
{ ...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow },
|
||||||
this.description = description;
|
{ ...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow },
|
||||||
// Ensure icon is always an array - convert single string to array if needed
|
{ ...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow },
|
||||||
this.icons = Array.isArray(icons) ? icons : [icons];
|
{ ...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow },
|
||||||
this.estimatedDuration = estimatedDuration;
|
];
|
||||||
this.steps = steps;
|
/**
|
||||||
}
|
* The full workflow catalog: every workflow type known to the frontend,
|
||||||
|
* whether it has a custom UI or not.
|
||||||
/**
|
*/
|
||||||
* Validate if the workflow can be started
|
const workflows = implementedWorkflows;
|
||||||
* Override in subclasses for specific validation logic
|
/**
|
||||||
*/
|
* Get every workflow in the catalog.
|
||||||
validate() {
|
* @returns {Array<Object>}
|
||||||
return true;
|
*/
|
||||||
}
|
export function getAllWorkflows() {
|
||||||
|
return workflows;
|
||||||
/**
|
}
|
||||||
* Get the initial payload structure for this workflow
|
/**
|
||||||
* Override in subclasses to provide workflow-specific payload
|
* Get a single workflow definition by id.
|
||||||
*/
|
* @param {string} id
|
||||||
getInitialPayload() {
|
* @returns {Object|undefined}
|
||||||
return {
|
*/
|
||||||
|
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: {
|
workflow_config: {
|
||||||
name: this.name,
|
name: workflow.name,
|
||||||
description: this.description,
|
description: workflow.description,
|
||||||
category: this.category,
|
category: workflow.category,
|
||||||
estimated_duration: this.estimatedDuration
|
estimated_duration: workflow.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
|
|
||||||
},
|
},
|
||||||
mapping: {},
|
...ownPayload
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
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
|
* The backend stores WorkflowInstance.payload as an opaque string - it never
|
||||||
* parses or understands it as JSON. The frontend is fully responsible for
|
* 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;
|
if (!workflow || !('payload' in workflow)) return workflow;
|
||||||
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
|
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeWorkflowPayload(workflow) {
|
export function deserializeWorkflowPayload(workflow) {
|
||||||
if (!workflow) return workflow;
|
if (!workflow) return workflow;
|
||||||
let payload = {};
|
let payload = {};
|
||||||
|
|
@ -424,19 +124,13 @@ export function deserializeWorkflowPayload(workflow) {
|
||||||
}
|
}
|
||||||
return {...workflow, payload};
|
return {...workflow, payload};
|
||||||
}
|
}
|
||||||
|
export default {
|
||||||
// Export individual classes for direct use
|
getAllWorkflows,
|
||||||
export {
|
getWorkflow,
|
||||||
BaseWorkflow,
|
getWorkflowComponent,
|
||||||
InventoryAuditWorkflow,
|
getWorkflowsByCategory,
|
||||||
StorageOptimizationWorkflow,
|
getWorkflowCategories,
|
||||||
FotoFirstImportWorkflow,
|
buildWorkflowApiPayload,
|
||||||
MaintenanceScheduleWorkflow,
|
serializeWorkflowPayload,
|
||||||
ExpiryCheckWorkflow,
|
deserializeWorkflowPayload
|
||||||
DataBackupWorkflow,
|
|
||||||
BulkItemImportWorkflow,
|
|
||||||
WorkflowRegistry
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export default registry
|
|
||||||
export default workflowRegistry;
|
|
||||||
|
|
|
||||||
512
testdata/generate_testdata.py
vendored
Normal file
512
testdata/generate_testdata.py
vendored
Normal 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
2
testdata/user-a.key
vendored
|
|
@ -1 +1 @@
|
||||||
2a7ddeb75181afedbc755924db9f1d527278375ff562858c93a502ab783a6815
|
795e884532881938a1b03e37731443f4d1974327dafd5d508e0cb00cb0b161b6
|
||||||
BIN
testdata/user-a.zip
vendored
BIN
testdata/user-a.zip
vendored
Binary file not shown.
2
testdata/user-b.key
vendored
2
testdata/user-b.key
vendored
|
|
@ -1 +1 @@
|
||||||
5b0f8e19806c5ab87eced9a0d08855429e1b2be957b760b46fbbef2309eccd43
|
d011fedc3ba733baef6aa7550bbee47db66c2ad3dbfb0f6dcc14c984866df5eb
|
||||||
BIN
testdata/user-b.zip
vendored
BIN
testdata/user-b.zip
vendored
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue