stash
This commit is contained in:
parent
7c91661be2
commit
796fef81be
37 changed files with 3404 additions and 108 deletions
|
|
@ -11,6 +11,8 @@ from rest_framework.response import Response
|
|||
|
||||
from authentication.models import ToolshedUser
|
||||
from authentication.signature_auth import SignatureAuthenticationLocal
|
||||
from files.models import File
|
||||
from files.serializers import FileSerializer
|
||||
from hostadmin.models import Domain
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
|
|
@ -53,15 +55,43 @@ class UserViewSet(viewsets.ModelViewSet):
|
|||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@api_view(['GET', 'PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@authentication_classes([SignatureAuthenticationLocal])
|
||||
def getUserInfo(request):
|
||||
user = request.user
|
||||
if request.method == 'PATCH':
|
||||
old_file = user.profile_picture
|
||||
if 'profile_picture' in request.data:
|
||||
profile_picture = request.data.get('profile_picture')
|
||||
if profile_picture is None:
|
||||
user.profile_picture = None
|
||||
elif type(profile_picture) == dict:
|
||||
serializer = FileSerializer(data=profile_picture)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=400)
|
||||
user.profile_picture = serializer.save()
|
||||
else:
|
||||
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
|
||||
elif 'profile_picture_id' in request.data:
|
||||
profile_picture_id = request.data.get('profile_picture_id')
|
||||
if profile_picture_id is None:
|
||||
user.profile_picture = None
|
||||
else:
|
||||
try:
|
||||
user.profile_picture = File.objects.get(id=profile_picture_id)
|
||||
except File.DoesNotExist:
|
||||
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
|
||||
user.save()
|
||||
|
||||
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0:
|
||||
old_file.delete()
|
||||
|
||||
return Response({
|
||||
'username': user.username,
|
||||
'domain': user.domain,
|
||||
'email': user.email
|
||||
'email': user.email,
|
||||
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('files', '0001_initial'),
|
||||
('authentication', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='toolsheduser',
|
||||
name='profile_picture',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='profile_picture_users', to='files.file'),
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
|
|||
domain = models.CharField(max_length=255, default='localhost')
|
||||
private_key = models.CharField(max_length=255)
|
||||
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
|
||||
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='profile_picture_users')
|
||||
objects = ToolshedUserManager()
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import base64
|
||||
|
||||
from django.test import Client, RequestFactory
|
||||
from nacl.encoding import HexEncoder
|
||||
|
|
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
|
|||
|
||||
from authentication.models import ToolshedUser, KnownIdentity
|
||||
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
|
||||
from files.models import File
|
||||
|
||||
|
||||
class AuthorizationTestCase(ToolshedTestCase):
|
||||
|
|
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertEqual(reply.json()['email'], 'test1@abc.de')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
|
||||
def test_user_info2(self):
|
||||
target = "/auth/user/"
|
||||
|
|
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
|
|||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['username'], 'testuser1')
|
||||
self.assertEqual(reply.json()['domain'], 'example.com')
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
|
||||
def test_user_info_patch_profile_picture(self):
|
||||
content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
|
||||
'profile_picture': {
|
||||
'data': content,
|
||||
'mime_type': 'image/png'
|
||||
}
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertTrue(reply.json()['profile_picture'])
|
||||
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
|
||||
self.assertEqual(File.objects.count(), 1)
|
||||
self.f['local_user1'].refresh_from_db()
|
||||
self.assertIsNotNone(self.f['local_user1'].profile_picture)
|
||||
|
||||
def test_user_info_patch_profile_picture_clear(self):
|
||||
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
|
||||
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
|
||||
self.f['local_user1'].profile_picture = test_file
|
||||
self.f['local_user1'].save()
|
||||
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertIsNone(reply.json()['profile_picture'])
|
||||
self.f['local_user1'].refresh_from_db()
|
||||
self.assertIsNone(self.f['local_user1'].profile_picture)
|
||||
self.assertFalse(File.objects.filter(id=test_file.id).exists())
|
||||
|
||||
def test_user_info_patch_profile_picture_invalid(self):
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_user_info_patch_profile_picture_id(self):
|
||||
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
|
||||
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
|
||||
|
||||
def test_user_info_patch_profile_picture_id_not_found(self):
|
||||
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
|
||||
self.assertEqual(reply.status_code, 400)
|
||||
|
||||
def test_user_info_fail(self):
|
||||
reply = self.anonymous_client.get('/auth/user/')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import path
|
||||
from django.db.models import Q
|
||||
from django.conf import settings
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes, authentication_classes
|
||||
|
|
@ -7,7 +9,6 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.response import Response
|
||||
|
||||
from authentication.signature_auth import SignatureAuthentication
|
||||
from backend import settings
|
||||
from files.models import File
|
||||
|
||||
|
||||
|
|
@ -17,7 +18,10 @@ from files.models import File
|
|||
@authentication_classes([SignatureAuthentication])
|
||||
def media_urls(request, hash_path):
|
||||
try:
|
||||
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
|
||||
file = File.objects.filter(
|
||||
Q(connected_items__owner__in=request.user.friends_or_self()) |
|
||||
Q(profile_picture_users__in=request.user.friends_or_self())
|
||||
).distinct().get(
|
||||
file=hash_path)
|
||||
|
||||
if settings.SERVE_X_ACCEL_REDIRECT:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import DefaultStorage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.test import Client
|
||||
from django.test import Client, override_settings
|
||||
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
|
||||
from toolshed.tests import InventoryTestMixin
|
||||
from nacl.hash import sha256
|
||||
|
|
@ -165,3 +165,33 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
|
|||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertTrue('X-Accel-Redirect' not in reply.headers)
|
||||
|
||||
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||
def test_profile_picture_url(self):
|
||||
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||
self.f['local_user1'].save()
|
||||
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||
self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
|
||||
def test_profile_picture_url_friend(self):
|
||||
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||
self.f['local_user1'].save()
|
||||
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||
self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
|
||||
def test_profile_picture_url_not_friend(self):
|
||||
self.f['local_user1'].profile_picture = self.f['test_file3']
|
||||
self.f['local_user1'].save()
|
||||
|
||||
reply = client.get(
|
||||
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
|
||||
self.f['ext_user1'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 4.2.2 on 2026-07-23 02:02
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('toolshed', '0007_workflowinstance'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='storage_location',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'),
|
||||
),
|
||||
]
|
||||
|
|
@ -90,7 +90,7 @@ class InventoryItem(SoftDeleteModel):
|
|||
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
|
||||
properties = models.ManyToManyField(Property, through='ItemProperty')
|
||||
files = models.ManyToManyField(File, related_name='connected_items')
|
||||
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True,
|
||||
storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='inventory_items')
|
||||
|
||||
def clean(self):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
|
||||
from files.tests import FilesTestMixin
|
||||
from toolshed.models import InventoryItem, Category
|
||||
from toolshed.models import InventoryItem, Category, StorageLocation
|
||||
from toolshed.tests import InventoryTestMixin, LocationTestMixin
|
||||
|
||||
client = SignatureAuthClient()
|
||||
|
|
@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
|
|||
self.assertEqual(reply.json()[3]['description'], None)
|
||||
self.assertEqual(reply.json()[3]['category'], 'cat1')
|
||||
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
|
||||
|
||||
def test_post_new_location(self):
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
'name': 'loc5',
|
||||
'description': 'a new location',
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
self.assertEqual(StorageLocation.objects.count(), 5)
|
||||
location = StorageLocation.objects.get(name='loc5')
|
||||
self.assertEqual(location.description, 'a new location')
|
||||
self.assertEqual(location.owner, self.f['local_user1'])
|
||||
self.assertEqual(location.parent, None)
|
||||
self.assertEqual(reply.json()['path'], 'loc5')
|
||||
|
||||
def test_post_new_nested_location(self):
|
||||
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
|
||||
'name': 'loc5',
|
||||
'parent': self.f['loc3'].id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 201)
|
||||
location = StorageLocation.objects.get(name='loc5')
|
||||
self.assertEqual(location.parent, self.f['loc3'])
|
||||
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
|
||||
|
||||
def test_patch_location(self):
|
||||
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
|
||||
'name': 'loc2-renamed',
|
||||
'parent': self.f['loc1'].id,
|
||||
})
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
location = StorageLocation.objects.get(id=self.f['loc2'].id)
|
||||
self.assertEqual(location.name, 'loc2-renamed')
|
||||
self.assertEqual(location.parent, self.f['loc1'])
|
||||
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
|
||||
|
||||
def test_delete_location(self):
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
self.assertEqual(StorageLocation.objects.count(), 3)
|
||||
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
|
||||
|
||||
def test_delete_location_with_items_sets_null(self):
|
||||
item = InventoryItem.objects.create(
|
||||
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
|
||||
self.assertEqual(reply.status_code, 204)
|
||||
item.refresh_from_db()
|
||||
self.assertIsNone(item.storage_location)
|
||||
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
|
||||
|
||||
def test_locations_are_owner_scoped(self):
|
||||
reply = client.get('/api/storage_locations/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 200)
|
||||
self.assertEqual(len(reply.json()), 0)
|
||||
|
||||
def test_cannot_delete_other_users_location(self):
|
||||
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
|
||||
self.assertEqual(reply.status_code, 404)
|
||||
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,3 @@
|
|||
[
|
||||
[
|
||||
{
|
||||
"speed": 1,
|
||||
"position": 1
|
||||
},
|
||||
{
|
||||
"speed": 1,
|
||||
"position": 1
|
||||
},
|
||||
{
|
||||
"speed": 1,
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
|
||||
arr[1].speed
|
||||
"a.localhost"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ http {
|
|||
}
|
||||
|
||||
upstream dns {
|
||||
server dns:8053;
|
||||
server toolshed-dns:8053;
|
||||
}
|
||||
|
||||
server {
|
||||
|
|
@ -106,17 +106,35 @@ http {
|
|||
# DoH server
|
||||
server {
|
||||
listen 5353 ssl;
|
||||
server_name localhost;
|
||||
server_name localhost 127.0.0.3;
|
||||
|
||||
ssl_certificate /etc/nginx/nginx.crt;
|
||||
ssl_certificate_key /etc/nginx/nginx.key;
|
||||
|
||||
location /dns-query {
|
||||
proxy_pass http://dns;
|
||||
# allow any origin
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
|
||||
# Ensure CORS headers are present even when nginx generates 5xx responses.
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Type' always;
|
||||
error_page 500 502 503 504 = @doh_error;
|
||||
|
||||
location /dns-query {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Length' 0;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://dns;
|
||||
}
|
||||
|
||||
location @doh_error {
|
||||
default_type text/plain;
|
||||
return 502 'DoH upstream unavailable';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,3 +78,7 @@ services:
|
|||
- ./dev/zone.json:/dns/zone.json
|
||||
expose:
|
||||
- 8053
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- toolshed-dns
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ Start the fullstack application:
|
|||
docker-compose -f deploy/docker-compose.override.yml up --build
|
||||
```
|
||||
|
||||
Run backend tests in Docker:
|
||||
|
||||
``` bash
|
||||
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
|
||||
```
|
||||
|
||||
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
|
||||
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
|
||||
server is used to direct the frontend to the correct backend instance.
|
||||
|
|
|
|||
0
frontend/src/components/AuthenticatedAvatar.vue
Normal file
0
frontend/src/components/AuthenticatedAvatar.vue
Normal file
|
|
@ -1,15 +1,8 @@
|
|||
<template>
|
||||
<img :src="image_data" :title="owner + ':' + src"/>
|
||||
<img :src="image_data" :title="title || (owner + ':' + src)" :alt="alt" :class="imgClass"
|
||||
:width="width" :height="height" :style="{objectFit: fit}"/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
max-width: 190px;
|
||||
height: 107px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
|
||||
|
|
@ -18,36 +11,102 @@ export default {
|
|||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: 'image'
|
||||
},
|
||||
imgClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: '/assets/img/avatars/avatar.png'
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
fit: {
|
||||
type: String,
|
||||
default: 'contain'
|
||||
},
|
||||
refreshKey: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
image_data: "",
|
||||
servers: []
|
||||
servers: [],
|
||||
lastRequestId: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["signAuth"])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["getFriendServers"])
|
||||
},
|
||||
async mounted() {
|
||||
...mapActions(["getFriendServers"]),
|
||||
async loadImage() {
|
||||
const requestId = ++this.lastRequestId;
|
||||
if (!this.src) {
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = this.fallbackSrc;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.servers = await this.getFriendServers({username: this.owner});
|
||||
const response = await this.servers.getRaw(this.signAuth, this.src);
|
||||
const mime_type = response.headers.get("content-type");
|
||||
if (requestId !== this.lastRequestId) {
|
||||
return;
|
||||
}
|
||||
if (!response || !response.ok) {
|
||||
this.image_data = this.fallbackSrc;
|
||||
return;
|
||||
}
|
||||
const mime_type = response.headers.get("content-type") || 'image/png';
|
||||
const base64 = btoa(new Uint8Array(await response.arrayBuffer())
|
||||
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = "data:" + mime_type + ";base64," + base64;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
} catch (_e) {
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = this.fallbackSrc;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src() {
|
||||
this.loadImage();
|
||||
},
|
||||
owner() {
|
||||
this.loadImage();
|
||||
},
|
||||
refreshKey() {
|
||||
this.loadImage();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
</a>
|
||||
|
||||
<a class="nav-link dropdown-toggle d-none d-sm-inline-block" href="#" @click="toggleDropdown">
|
||||
<img src="/assets/img/avatars/avatar.png" class="avatar img-fluid rounded mr-1"
|
||||
:alt="username" :title="username">
|
||||
<authenticated-image :src="profilePictureSrc" :owner="username" :alt="username" :title="username"
|
||||
img-class="avatar img-fluid rounded mr-1" :width="36" :height="36" fit="cover"/>
|
||||
|
||||
</a>
|
||||
|
||||
|
|
@ -35,14 +35,17 @@
|
|||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
import {mapMutations, mapState} from "vuex";
|
||||
import {mapActions, mapMutations, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
name: "UserDropdown",
|
||||
components: {
|
||||
AuthenticatedImage,
|
||||
...BIcons
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchUserProfile']),
|
||||
...mapMutations(['logout']),
|
||||
toggleDropdown() {
|
||||
closeAllDropdowns();
|
||||
|
|
@ -50,10 +53,18 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user']),
|
||||
...mapState(['user', 'user_profile']),
|
||||
username() {
|
||||
return this.user;
|
||||
},
|
||||
profilePictureSrc() {
|
||||
return this.user_profile?.profile_picture?.name || null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.user_profile) {
|
||||
this.fetchUserProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="d-inline-block">
|
||||
<label for="files">
|
||||
<label :for="inputId">
|
||||
<slot></slot>
|
||||
</label>
|
||||
<input type="file" multiple id="files" @change="loadFiles" class="d-none">
|
||||
<input ref="fileInput" type="file" multiple :id="inputId" @change="loadFiles" class="d-none">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -19,9 +19,17 @@ export default {
|
|||
...BIcons
|
||||
},
|
||||
emits: ["input"],
|
||||
data() {
|
||||
return {
|
||||
inputId: `files-${this._uid}`
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
loadFiles() {
|
||||
const files = document.getElementById("files").files;
|
||||
const files = this.$refs.fileInput?.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const jobs = [...files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
|
|
@ -52,6 +60,10 @@ export default {
|
|||
});
|
||||
Promise.all(jobs).then((files) => {
|
||||
this.$emit("input", files)
|
||||
// Allow selecting the same file again to trigger change.
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
168
frontend/src/components/inputs/PreferenceInput.vue
Normal file
168
frontend/src/components/inputs/PreferenceInput.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="input-group input-group-sm">
|
||||
<div v-if="inputType === 'checkbox'" class="input-group-prepend">
|
||||
<div :class="['input-group-text', 'bg-transparent', showBorder ? 'border-secondary' : 'border-0']">
|
||||
<input
|
||||
:id="id"
|
||||
ref="checkboxInput"
|
||||
type="checkbox"
|
||||
autocomplete="off"
|
||||
:checked="isChecked"
|
||||
:disabled="disabled"
|
||||
@change="handleCheckboxChange"
|
||||
>
|
||||
</div>
|
||||
<input type="text" class="form-control" value="" disabled style="display: none">
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-else-if="inputType === 'select'"
|
||||
:id="id"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
@change="handleSelectChange"
|
||||
>
|
||||
<option value="">{{ disabled ? '-' : '(not set)' }}</option>
|
||||
<option v-for="option in schema.options || []" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-else-if="inputType === 'number'"
|
||||
:id="id"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
:min="schema.validation_rules ? schema.validation_rules.min : undefined"
|
||||
:max="schema.validation_rules ? schema.validation_rules.max : undefined"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleNumberInput"
|
||||
>
|
||||
|
||||
<input
|
||||
v-else-if="inputType === 'text'"
|
||||
:id="id"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleTextInput"
|
||||
>
|
||||
|
||||
<textarea
|
||||
v-else-if="inputType === 'json'"
|
||||
:id="id"
|
||||
class="form-control form-control-sm"
|
||||
rows="3"
|
||||
style="font-family: monospace; font-size: 0.8rem"
|
||||
:value="jsonDisplayValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleJsonInput"
|
||||
></textarea>
|
||||
|
||||
<div v-if="showClear" class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" title="Clear preference" @click="handleClear">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PreferenceInput',
|
||||
props: {
|
||||
id: {type: String, required: true},
|
||||
schema: {type: Object, required: true},
|
||||
value: {default: null},
|
||||
disabled: {type: Boolean, default: false},
|
||||
},
|
||||
emits: ['input', 'clear'],
|
||||
computed: {
|
||||
inputType() {
|
||||
const typeMapping = {
|
||||
boolean: 'checkbox',
|
||||
integer: 'number',
|
||||
float: 'number',
|
||||
enum: 'select',
|
||||
string: 'text',
|
||||
json: 'json',
|
||||
}
|
||||
return typeMapping[this.schema.type] || 'text'
|
||||
},
|
||||
isSet() {
|
||||
return this.value !== null && this.value !== undefined
|
||||
},
|
||||
isChecked() {
|
||||
return this.isSet ? Boolean(this.value) : false
|
||||
},
|
||||
showBorder() {
|
||||
return this.inputType === 'checkbox' && this.isSet && !this.disabled
|
||||
},
|
||||
showClear() {
|
||||
return !this.disabled && this.isSet
|
||||
},
|
||||
displayValue() {
|
||||
return this.value ?? ''
|
||||
},
|
||||
jsonDisplayValue() {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : this.value
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateCheckboxIndeterminate()
|
||||
},
|
||||
updated() {
|
||||
this.updateCheckboxIndeterminate()
|
||||
},
|
||||
methods: {
|
||||
updateCheckboxIndeterminate() {
|
||||
if (this.inputType !== 'checkbox') {
|
||||
return
|
||||
}
|
||||
const input = this.$refs.checkboxInput
|
||||
if (input) {
|
||||
input.indeterminate = !this.isSet
|
||||
}
|
||||
},
|
||||
handleCheckboxChange(e) {
|
||||
this.$emit('input', e.target.checked)
|
||||
},
|
||||
handleSelectChange(e) {
|
||||
const val = e.target.value
|
||||
this.$emit('input', val === '' ? null : val)
|
||||
},
|
||||
handleNumberInput(e) {
|
||||
const raw = e.target.value
|
||||
const val = raw === '' ? null : parseFloat(raw)
|
||||
this.$emit('input', Number.isNaN(val) ? null : val)
|
||||
},
|
||||
handleTextInput(e) {
|
||||
const val = e.target.value
|
||||
this.$emit('input', val === '' ? null : val)
|
||||
},
|
||||
handleJsonInput(e) {
|
||||
const val = e.target.value.trim()
|
||||
if (val === '') {
|
||||
this.$emit('input', null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.$emit('input', JSON.parse(val))
|
||||
} catch {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
handleClear() {
|
||||
this.$emit('clear')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
110
frontend/src/components/workflow/ComponentRegistry.js
Normal file
110
frontend/src/components/workflow/ComponentRegistry.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
// 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
|
||||
};
|
||||
193
frontend/src/components/workflow/ExampleUsage.js
Normal file
193
frontend/src/components/workflow/ExampleUsage.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
146
frontend/src/components/workflow/README.md
Normal file
146
frontend/src/components/workflow/README.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Workflow Step Component Dispatching System
|
||||
|
||||
## Overview
|
||||
|
||||
This system enables dynamic dispatching of Vue.js components based on workflow type and current step in the WorkflowDetail view. It provides a flexible, extensible architecture for creating custom step-specific user interfaces for different workflow types.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Component Registry (`/components/workflow/ComponentRegistry.js`)
|
||||
|
||||
The central registry that maps workflow types and steps to their corresponding Vue components using the format: `workflowType-step` → Component.
|
||||
|
||||
**Key Functions:**
|
||||
- `getStepComponent(workflowType, step)` - Retrieves a step component
|
||||
- `registerStepComponent(workflowType, step, component)` - Registers new components
|
||||
- `hasStepComponent(workflowType, step)` - Checks component existence
|
||||
- `getWorkflowComponents(workflowType)` - Gets all components for a workflow
|
||||
- `getRegisteredWorkflowTypes()` - Lists all registered workflow types
|
||||
|
||||
### 2. Step Components (`/components/workflow/steps/`)
|
||||
|
||||
Individual Vue components that handle specific workflow steps:
|
||||
|
||||
#### Foto First Import Workflow
|
||||
- **FotoFirstStep1.vue** - Photo capture/upload with camera and file upload support
|
||||
- **FotoFirstStep2.vue** - Image processing with compression and optimization
|
||||
- **FotoFirstStep3.vue** - Item details entry with form-based data collection
|
||||
- **FotoFirstStep4.vue** - Import completion with summary and finalization
|
||||
|
||||
#### Bulk Import Workflow
|
||||
- **BulkImportStep1.vue** - File upload with CSV/Excel support and column mapping
|
||||
|
||||
### 3. Dynamic Component Loading in WorkflowDetail.vue
|
||||
|
||||
The `stepComponent` computed property now uses the registry:
|
||||
|
||||
```javascript
|
||||
stepComponent() {
|
||||
const workflowType = this.workflowInstance?.workflow_type;
|
||||
if (workflowType && this.currentStep) {
|
||||
return getStepComponent(workflowType, this.currentStep);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## How Content Dispatching Works
|
||||
|
||||
### 1. **Workflow Active State Management**
|
||||
- Components connect to Vuex store's `active_workflows` state
|
||||
- `loadWorkflowInstance()` fetches and finds specific workflow instances
|
||||
- State determines which workflow type and step are active
|
||||
|
||||
### 2. **Dynamic Component Resolution**
|
||||
- System looks up components using workflow type + step combination
|
||||
- Registry returns the appropriate Vue component or null
|
||||
- Vue's `<component :is="stepComponent">` renders the resolved component
|
||||
|
||||
### 3. **Component Communication**
|
||||
- Step components receive props: `workflowInstance`, `step`, `payload`
|
||||
- Components emit events: `@update`, `@next`, `@prev`, `@complete`
|
||||
- Parent WorkflowDetail handles state updates and navigation
|
||||
|
||||
### 4. **Workflow Active Classes**
|
||||
- `isStepCurrent(stepNumber)` - Identifies active step
|
||||
- `isStepCompleted(stepNumber)` - Tracks completed steps
|
||||
- `getStepClass(stepNumber)` - Applies appropriate CSS classes:
|
||||
- `step-indicator-completed bg-success` - Completed steps
|
||||
- `step-indicator-current bg-primary text-white` - Current step
|
||||
- `step-indicator-pending bg-light border` - Pending steps
|
||||
|
||||
## Component Features
|
||||
|
||||
### FotoFirstStep1 (Photo Capture)
|
||||
- **Camera Integration**: Uses `navigator.mediaDevices.getUserMedia()`
|
||||
- **File Upload**: Drag-and-drop and file selection
|
||||
- **Image Preview**: Real-time photo gallery
|
||||
- **Data Persistence**: Photos saved to workflow payload
|
||||
|
||||
### FotoFirstStep2 (Image Processing)
|
||||
- **Batch Processing**: Processes multiple images sequentially
|
||||
- **Image Optimization**: Compression and resizing
|
||||
- **Progress Tracking**: Visual progress indicators
|
||||
- **Processing Options**: Configurable compression and dimensions
|
||||
|
||||
### FotoFirstStep3 (Item Details)
|
||||
- **Item-by-Item Entry**: Navigate through captured photos
|
||||
- **Comprehensive Forms**: Name, category, quantity, location, pricing
|
||||
- **Progress Tracking**: Shows completion status
|
||||
- **Data Validation**: Required field validation
|
||||
- **Edit Support**: Ability to modify previously entered items
|
||||
|
||||
### FotoFirstStep4 (Completion)
|
||||
- **Import Summary**: Statistics and breakdowns
|
||||
- **Data Review**: Grid and list view of items
|
||||
- **Final Options**: QR codes, notifications, reports
|
||||
- **Completion Workflow**: Final import execution
|
||||
|
||||
### BulkImportStep1 (File Upload)
|
||||
- **File Type Support**: CSV and Excel files
|
||||
- **Drag-and-Drop**: Modern file upload interface
|
||||
- **Column Mapping**: Automatic and manual field mapping
|
||||
- **Data Preview**: Shows first 5 rows of data
|
||||
- **Template Download**: Provides sample CSV template
|
||||
- **File Analysis**: Validates data structure and content
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding New Workflow Types
|
||||
1. Create step components in `/components/workflow/steps/`
|
||||
2. Import components in `ComponentRegistry.js`
|
||||
3. Add mappings to `componentRegistry` object
|
||||
4. Define workflow in `workflows.js`
|
||||
|
||||
### Adding New Steps
|
||||
1. Create component: `WorkflowTypeStepN.vue`
|
||||
2. Import and register in ComponentRegistry
|
||||
3. Update workflow step definitions
|
||||
4. Component automatically dispatched when step is reached
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Modularity**: Each step is an independent, reusable component
|
||||
2. **Flexibility**: Easy to create workflow-specific UI experiences
|
||||
3. **Maintainability**: Clear separation of concerns
|
||||
4. **Extensibility**: Simple process to add new workflows and steps
|
||||
5. **Type Safety**: Registry provides centralized component management
|
||||
6. **Performance**: Components only loaded when needed
|
||||
7. **Consistency**: Standardized props and events across all step components
|
||||
|
||||
## 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.
|
||||
480
frontend/src/components/workflow/steps/BulkImportStep1.vue
Normal file
480
frontend/src/components/workflow/steps/BulkImportStep1.vue
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
<template>
|
||||
<div class="bulk-import-step-1">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">File Upload</h4>
|
||||
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Area -->
|
||||
<div class="upload-section mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div v-if="!uploadedFile" class="upload-dropzone text-center py-5"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
:class="{ 'dragover': isDragOver }"
|
||||
@dragenter="isDragOver = true"
|
||||
@dragleave="isDragOver = false">
|
||||
|
||||
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
|
||||
<h5 class="mb-3">Upload Your Data File</h5>
|
||||
<p class="text-muted mb-4">
|
||||
Drag and drop your CSV or Excel file here, or click to browse
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
@change="handleFileSelect"
|
||||
class="d-none"
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
|
||||
<b-icon-folder-open class="me-2"></b-icon-folder-open>
|
||||
Choose File
|
||||
</button>
|
||||
<button class="btn btn-outline-info" @click="downloadTemplate">
|
||||
<b-icon-download class="me-2"></b-icon-download>
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="supported-formats">
|
||||
<small class="text-muted">
|
||||
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Info Display -->
|
||||
<div v-else class="file-info">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<b-icon-file-earmark-spreadsheet class="text-success me-3" style="font-size: 2rem;"></b-icon-file-earmark-spreadsheet>
|
||||
<div>
|
||||
<h6 class="mb-1">{{ uploadedFile.name }}</h6>
|
||||
<small class="text-muted">
|
||||
{{ formatFileSize(uploadedFile.size) }} •
|
||||
{{ getFileType(uploadedFile.name) }} •
|
||||
Uploaded {{ formatDateTime(uploadTime) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" @click="removeFile">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File Analysis Results -->
|
||||
<div v-if="fileAnalysis" class="file-analysis">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary mb-1">{{ fileAnalysis.totalRows }}</h4>
|
||||
<small class="text-muted">Total Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info mb-1">{{ fileAnalysis.totalColumns }}</h4>
|
||||
<small class="text-muted">Columns</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success mb-1">{{ fileAnalysis.validRows }}</h4>
|
||||
<small class="text-muted">Valid Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning mb-1">{{ fileAnalysis.errorRows }}</h4>
|
||||
<small class="text-muted">Issues Found</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Processing Status -->
|
||||
<div v-if="processing" class="processing-status mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<div>
|
||||
<h6 class="mb-1">Processing file...</h6>
|
||||
<p class="text-muted mb-0">{{ processingStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Mapping -->
|
||||
<div v-if="fileAnalysis && !processing" class="column-mapping mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Column Mapping</h6>
|
||||
<small class="text-muted">Map your file columns to system fields</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="field in requiredFields" :key="field.key" class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select" v-model="columnMapping[field.key]">
|
||||
<option value="">Select column...</option>
|
||||
<option v-for="column in detectedColumns" :key="column" :value="column">
|
||||
{{ column }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preview -->
|
||||
<div v-if="previewData.length > 0" class="data-preview mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Data Preview</h6>
|
||||
<small class="text-muted">First 5 rows of your data</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in detectedColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in previewData.slice(0, 5)" :key="index">
|
||||
<td v-for="column in detectedColumns" :key="column">
|
||||
{{ row[column] || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedToNext"
|
||||
:disabled="!canProceed"
|
||||
>
|
||||
Next: Parse Data
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'BulkImportStep1',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
uploadedFile: null,
|
||||
uploadTime: null,
|
||||
processing: false,
|
||||
processingStatus: '',
|
||||
isDragOver: false,
|
||||
fileAnalysis: null,
|
||||
detectedColumns: [],
|
||||
previewData: [],
|
||||
columnMapping: {},
|
||||
requiredFields: [
|
||||
{ key: 'name', label: 'Item Name', required: true },
|
||||
{ key: 'category', label: 'Category', required: false },
|
||||
{ key: 'quantity', label: 'Quantity', required: false },
|
||||
{ key: 'unit', label: 'Unit', required: false },
|
||||
{ key: 'description', label: 'Description', required: false },
|
||||
{ key: 'location', label: 'Storage Location', required: false },
|
||||
{ key: 'purchase_price', label: 'Purchase Price', required: false },
|
||||
{ key: 'estimated_value', label: 'Estimated Value', required: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canProceed() {
|
||||
return this.uploadedFile && this.fileAnalysis && this.columnMapping.name;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing data if resuming
|
||||
if (this.payload.uploaded_file) {
|
||||
this.uploadedFile = this.payload.uploaded_file;
|
||||
this.uploadTime = this.payload.upload_time;
|
||||
this.fileAnalysis = this.payload.file_analysis;
|
||||
this.detectedColumns = this.payload.detected_columns || [];
|
||||
this.previewData = this.payload.preview_data || [];
|
||||
this.columnMapping = this.payload.column_mapping || {};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleFileDrop(event) {
|
||||
this.isDragOver = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
async processFile(file) {
|
||||
if (!this.isValidFileType(file)) {
|
||||
alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadedFile = file;
|
||||
this.uploadTime = new Date().toISOString();
|
||||
this.processing = true;
|
||||
this.processingStatus = 'Reading file...';
|
||||
|
||||
try {
|
||||
// Simulate file processing
|
||||
await this.analyzeFile(file);
|
||||
this.processingStatus = 'Analyzing data structure...';
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.processingStatus = 'Generating preview...';
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.updatePayload();
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error);
|
||||
alert('Error processing file. Please try again.');
|
||||
this.removeFile();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
this.processingStatus = '';
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeFile(file) {
|
||||
// This is a simplified version - in reality, you'd use a library like Papa Parse for CSV
|
||||
// or SheetJS for Excel files
|
||||
|
||||
if (file.name.endsWith('.csv')) {
|
||||
const text = await file.text();
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length > 0) {
|
||||
// Parse CSV header
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
this.detectedColumns = headers;
|
||||
|
||||
// Parse preview data
|
||||
this.previewData = lines.slice(1, 6).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
this.fileAnalysis = {
|
||||
totalRows: lines.length - 1, // Exclude header
|
||||
totalColumns: headers.length,
|
||||
validRows: lines.length - 1, // Simplified - assume all valid for demo
|
||||
errorRows: 0
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For Excel files, you'd use a library like SheetJS
|
||||
// This is a mock implementation
|
||||
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
||||
this.previewData = [
|
||||
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
||||
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
|
||||
];
|
||||
this.fileAnalysis = {
|
||||
totalRows: 100,
|
||||
totalColumns: 5,
|
||||
validRows: 98,
|
||||
errorRows: 2
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-map columns based on common names
|
||||
this.autoMapColumns();
|
||||
},
|
||||
|
||||
autoMapColumns() {
|
||||
const mapping = {};
|
||||
|
||||
this.detectedColumns.forEach(column => {
|
||||
const lowerColumn = column.toLowerCase();
|
||||
|
||||
if (lowerColumn.includes('name') || lowerColumn.includes('item')) {
|
||||
mapping.name = column;
|
||||
} else if (lowerColumn.includes('category') || lowerColumn.includes('type')) {
|
||||
mapping.category = column;
|
||||
} else if (lowerColumn.includes('quantity') || lowerColumn.includes('qty')) {
|
||||
mapping.quantity = column;
|
||||
} else if (lowerColumn.includes('unit')) {
|
||||
mapping.unit = column;
|
||||
} else if (lowerColumn.includes('description') || lowerColumn.includes('desc')) {
|
||||
mapping.description = column;
|
||||
} else if (lowerColumn.includes('location') || lowerColumn.includes('storage')) {
|
||||
mapping.location = column;
|
||||
} else if (lowerColumn.includes('price') && lowerColumn.includes('purchase')) {
|
||||
mapping.purchase_price = column;
|
||||
} else if (lowerColumn.includes('value') || lowerColumn.includes('price')) {
|
||||
mapping.estimated_value = column;
|
||||
}
|
||||
});
|
||||
|
||||
this.columnMapping = mapping;
|
||||
},
|
||||
|
||||
isValidFileType(file) {
|
||||
const validTypes = ['.csv', '.xlsx', '.xls'];
|
||||
return validTypes.some(type => file.name.toLowerCase().endsWith(type));
|
||||
},
|
||||
|
||||
getFileType(filename) {
|
||||
if (filename.endsWith('.csv')) return 'CSV';
|
||||
if (filename.endsWith('.xlsx') || filename.endsWith('.xls')) return 'Excel';
|
||||
return 'Unknown';
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
formatDateTime(dateString) {
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
removeFile() {
|
||||
this.uploadedFile = null;
|
||||
this.uploadTime = null;
|
||||
this.fileAnalysis = null;
|
||||
this.detectedColumns = [];
|
||||
this.previewData = [];
|
||||
this.columnMapping = {};
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
downloadTemplate() {
|
||||
// Create a sample CSV template
|
||||
const headers = ['Name', 'Category', 'Quantity', 'Unit', 'Description', 'Location', 'Purchase Price', 'Estimated Value'];
|
||||
const sampleData = [
|
||||
['Hammer', 'Tools', '1', 'piece', 'Claw hammer for general use', 'Toolbox A', '25.99', '30.00'],
|
||||
['Screws', 'Hardware', '100', 'pack', 'Wood screws 2 inch', 'Storage Bin 3', '12.50', '15.00']
|
||||
];
|
||||
|
||||
const csvContent = [headers, ...sampleData]
|
||||
.map(row => row.map(field => `"${field}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'inventory_import_template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
uploaded_file: this.uploadedFile,
|
||||
upload_time: this.uploadTime,
|
||||
file_analysis: this.fileAnalysis,
|
||||
detected_columns: this.detectedColumns,
|
||||
preview_data: this.previewData,
|
||||
column_mapping: this.columnMapping
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
if (!this.canProceed) {
|
||||
alert('Please upload a file and map the required Name column before proceeding.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-dropzone {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover,
|
||||
.upload-dropzone.dragover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-analysis {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
274
frontend/src/components/workflow/steps/FotoFirstStep1.vue
Normal file
274
frontend/src/components/workflow/steps/FotoFirstStep1.vue
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<template>
|
||||
<div class="foto-first-step-1">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Photo Capture</h4>
|
||||
<p class="text-muted">Capture or upload item photos to begin the import process.</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-area mb-4">
|
||||
<div class="row">
|
||||
<!-- Camera Capture -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera>
|
||||
<h6>Camera Capture</h6>
|
||||
<p class="text-muted small">Use your device camera to capture photos</p>
|
||||
<button class="btn btn-primary" @click="startCamera" :disabled="loading">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Start Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
|
||||
<h6>File Upload</h6>
|
||||
<p class="text-muted small">Upload photos from your device</p>
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
multiple
|
||||
accept="image/*"
|
||||
@change="handleFileUpload"
|
||||
class="d-none"
|
||||
/>
|
||||
<button class="btn btn-success" @click="$refs.fileInput.click()" :disabled="loading">
|
||||
<b-icon-upload class="me-1"></b-icon-upload>
|
||||
Upload Photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Preview -->
|
||||
<div v-if="showCamera" class="camera-section mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Camera Preview</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-x></b-icon-x>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="camera-container text-center">
|
||||
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
|
||||
<div>
|
||||
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Capture Photo
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-stop class="me-1"></b-icon-stop>
|
||||
Stop Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Captured Photos ({{ photos.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="clearAllPhotos">
|
||||
<b-icon-trash class="me-1"></b-icon-trash>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Photo {{ index + 1 }}</small>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="removePhoto(index)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedToNext"
|
||||
:disabled="photos.length === 0 || loading"
|
||||
>
|
||||
Next: Process Images
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep1',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
showCamera: false,
|
||||
cameraReady: false,
|
||||
photos: [],
|
||||
stream: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing photos from payload
|
||||
if (this.payload.photos) {
|
||||
this.photos = [...this.payload.photos];
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopCamera();
|
||||
},
|
||||
methods: {
|
||||
async startCamera() {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' }
|
||||
});
|
||||
this.$refs.video.srcObject = this.stream;
|
||||
this.showCamera = true;
|
||||
this.cameraReady = true;
|
||||
} catch (error) {
|
||||
console.error('Error accessing camera:', error);
|
||||
alert('Could not access camera. Please check permissions or use file upload instead.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.showCamera = false;
|
||||
this.cameraReady = false;
|
||||
},
|
||||
|
||||
capturePhoto() {
|
||||
if (!this.cameraReady) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const video = this.$refs.video;
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
const photo = {
|
||||
file: blob,
|
||||
preview: URL.createObjectURL(blob),
|
||||
name: `camera-photo-${Date.now()}.jpg`,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.photos.push(photo);
|
||||
this.updatePayload();
|
||||
}, 'image/jpeg', 0.8);
|
||||
},
|
||||
|
||||
handleFileUpload(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const photo = {
|
||||
file: file,
|
||||
preview: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.photos.push(photo);
|
||||
}
|
||||
});
|
||||
this.updatePayload();
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
removePhoto(index) {
|
||||
URL.revokeObjectURL(this.photos[index].preview);
|
||||
this.photos.splice(index, 1);
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
|
||||
this.photos = [];
|
||||
this.updatePayload();
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', { photos: this.photos });
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.photo-thumbnail {
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-area .card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.upload-area .card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
360
frontend/src/components/workflow/steps/FotoFirstStep2.vue
Normal file
360
frontend/src/components/workflow/steps/FotoFirstStep2.vue
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
<template>
|
||||
<div class="foto-first-step-2">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Image Processing</h4>
|
||||
<p class="text-muted">Processing and optimizing your captured images...</p>
|
||||
</div>
|
||||
|
||||
<!-- Processing Status -->
|
||||
<div class="processing-status mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Processing Progress</h6>
|
||||
<span class="badge bg-primary">{{ processedCount }}/{{ totalPhotos }}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress mb-3" style="height: 12px;">
|
||||
<div
|
||||
class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
:style="{ width: progressPercentage + '%' }"
|
||||
:class="{ 'bg-success': isComplete, 'bg-primary': !isComplete }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="processing-details">
|
||||
<div v-if="currentlyProcessing" class="d-flex align-items-center text-muted">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<span>Processing: {{ currentlyProcessing }}</span>
|
||||
</div>
|
||||
<div v-else-if="isComplete" class="d-flex align-items-center text-success">
|
||||
<b-icon-check-circle class="me-2"></b-icon-check-circle>
|
||||
<span>All images processed successfully!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Options -->
|
||||
<div class="processing-options mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Processing Options</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="autoRotate"
|
||||
v-model="processingOptions.auto_rotate"
|
||||
:disabled="processing"
|
||||
>
|
||||
<label class="form-check-label" for="autoRotate">
|
||||
Auto-rotate images based on EXIF data
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-2">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="compress"
|
||||
v-model="processingOptions.compress"
|
||||
:disabled="processing"
|
||||
>
|
||||
<label class="form-check-label" for="compress">
|
||||
Compress images for optimal storage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="processingOptions.max_width"
|
||||
:disabled="processing"
|
||||
min="480"
|
||||
max="4096"
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="processingOptions.max_height"
|
||||
:disabled="processing"
|
||||
min="480"
|
||||
max="4096"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processed Images Preview -->
|
||||
<div v-if="processedImages.length > 0" class="processed-images mb-4">
|
||||
<h6 class="mb-3">Processed Images</h6>
|
||||
<div class="row">
|
||||
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<img :src="image.processedUrl" class="card-img-top processed-thumbnail" :alt="`Processed ${index + 1}`">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">{{ image.name }}</small>
|
||||
<span class="badge bg-success">
|
||||
<b-icon-check></b-icon-check>
|
||||
</span>
|
||||
</div>
|
||||
<div class="processing-info">
|
||||
<small class="text-muted d-block">
|
||||
{{ formatFileSize(image.originalSize) }} → {{ formatFileSize(image.processedSize) }}
|
||||
</small>
|
||||
<small class="text-success">
|
||||
{{ Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) }}% reduced
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')" :disabled="processing">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
v-if="!processing && !isComplete"
|
||||
class="btn btn-primary"
|
||||
@click="startProcessing"
|
||||
>
|
||||
<b-icon-gear class="me-1"></b-icon-gear>
|
||||
Start Processing
|
||||
</button>
|
||||
<button
|
||||
v-if="isComplete"
|
||||
class="btn btn-success"
|
||||
@click="proceedToNext"
|
||||
>
|
||||
Next: Enter Item Details
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep2',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
processedCount: 0,
|
||||
currentlyProcessing: null,
|
||||
processedImages: [],
|
||||
processingOptions: {
|
||||
auto_rotate: true,
|
||||
compress: true,
|
||||
max_width: 1920,
|
||||
max_height: 1080
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalPhotos() {
|
||||
return this.payload.photos?.length || 0;
|
||||
},
|
||||
progressPercentage() {
|
||||
return this.totalPhotos > 0 ? (this.processedCount / this.totalPhotos) * 100 : 0;
|
||||
},
|
||||
isComplete() {
|
||||
return this.processedCount === this.totalPhotos && this.totalPhotos > 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load processing options from payload
|
||||
if (this.payload.processing_options) {
|
||||
this.processingOptions = { ...this.processingOptions, ...this.payload.processing_options };
|
||||
}
|
||||
|
||||
// Load processed images if they exist
|
||||
if (this.payload.processed_images) {
|
||||
this.processedImages = [...this.payload.processed_images];
|
||||
this.processedCount = this.processedImages.length;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async startProcessing() {
|
||||
if (!this.payload.photos || this.payload.photos.length === 0) {
|
||||
alert('No photos to process. Please go back and add photos first.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
this.processedCount = 0;
|
||||
this.processedImages = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < this.payload.photos.length; i++) {
|
||||
const photo = this.payload.photos[i];
|
||||
this.currentlyProcessing = photo.name;
|
||||
|
||||
const processedImage = await this.processImage(photo);
|
||||
this.processedImages.push(processedImage);
|
||||
this.processedCount++;
|
||||
|
||||
// Small delay to show progress
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
this.currentlyProcessing = null;
|
||||
this.updatePayload();
|
||||
} catch (error) {
|
||||
console.error('Error processing images:', error);
|
||||
alert('Error processing images. Please try again.');
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async processImage(photo) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Calculate new dimensions
|
||||
let { width, height } = this.calculateDimensions(
|
||||
img.width,
|
||||
img.height,
|
||||
this.processingOptions.max_width,
|
||||
this.processingOptions.max_height
|
||||
);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob(blob => {
|
||||
const processedImage = {
|
||||
name: photo.name,
|
||||
originalSize: photo.file.size,
|
||||
processedSize: blob.size,
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedFile: blob,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
resolve(processedImage);
|
||||
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
|
||||
};
|
||||
img.src = photo.preview;
|
||||
});
|
||||
},
|
||||
|
||||
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
|
||||
// Scale down if needed
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height;
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
return { width: Math.round(width), height: Math.round(height) };
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
processing_options: this.processingOptions,
|
||||
processed_images: this.processedImages
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
// Clean up object URLs
|
||||
this.processedImages.forEach(image => {
|
||||
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image.processedUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.processed-thumbnail {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.processing-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.progress-bar-animated {
|
||||
animation: progress-bar-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-stripes {
|
||||
0% {
|
||||
background-position: 1rem 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
419
frontend/src/components/workflow/steps/FotoFirstStep3.vue
Normal file
419
frontend/src/components/workflow/steps/FotoFirstStep3.vue
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<template>
|
||||
<div class="foto-first-step-3">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Item Details Entry</h4>
|
||||
<p class="text-muted">Enter details for each photographed item to complete the inventory import.</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="progress-indicator mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Item Progress</h6>
|
||||
<span class="badge bg-info">{{ currentItemIndex + 1 }} of {{ totalItems }}</span>
|
||||
</div>
|
||||
<div class="progress mb-2" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar bg-info"
|
||||
:style="{ width: itemProgressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Item Display -->
|
||||
<div v-if="currentItem" class="current-item mb-4">
|
||||
<div class="row">
|
||||
<!-- Image Preview -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" alt="Current item">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">{{ currentItem.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Details Form -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Item Details</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="saveCurrentItem">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Item Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="currentItemDetails.name"
|
||||
required
|
||||
placeholder="Enter item name"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" v-model="currentItemDetails.category">
|
||||
<option value="">Select category...</option>
|
||||
<option value="tools">Tools</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="hardware">Hardware</option>
|
||||
<option value="materials">Materials</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.quantity"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Unit</label>
|
||||
<select class="form-select" v-model="currentItemDetails.unit">
|
||||
<option value="piece">Piece</option>
|
||||
<option value="set">Set</option>
|
||||
<option value="box">Box</option>
|
||||
<option value="pack">Pack</option>
|
||||
<option value="meter">Meter</option>
|
||||
<option value="kilogram">Kilogram</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Condition</label>
|
||||
<select class="form-select" v-model="currentItemDetails.condition">
|
||||
<option value="new">New</option>
|
||||
<option value="excellent">Excellent</option>
|
||||
<option value="good">Good</option>
|
||||
<option value="fair">Fair</option>
|
||||
<option value="poor">Poor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
rows="3"
|
||||
v-model="currentItemDetails.description"
|
||||
placeholder="Optional description or notes"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Storage Location</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="currentItemDetails.location"
|
||||
placeholder="e.g., Shelf A, Drawer 3, etc."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Purchase Price</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.purchase_price"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Estimated Value</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input
|
||||
type="number"
|
||||
class="form-control"
|
||||
v-model.number="currentItemDetails.estimated_value"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Navigation -->
|
||||
<div class="item-navigation mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="previousItem"
|
||||
:disabled="currentItemIndex === 0"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous Item
|
||||
</button>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" @click="saveCurrentItem">
|
||||
<b-icon-check class="me-1"></b-icon-check>
|
||||
Save Item
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" @click="skipCurrentItem">
|
||||
<b-icon-skip-forward class="me-1"></b-icon-skip-forward>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="nextItem"
|
||||
:disabled="currentItemIndex >= totalItems - 1"
|
||||
>
|
||||
Next Item
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Items Summary -->
|
||||
<div v-if="completedItems.length > 0" class="completed-items mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Completed Items ({{ completedItems.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-info" @click="showCompleted = !showCompleted">
|
||||
<b-icon-eye v-if="!showCompleted"></b-icon-eye>
|
||||
<b-icon-eye-slash v-else></b-icon-eye-slash>
|
||||
{{ showCompleted ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showCompleted" class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="completed-item-thumb me-2" alt="Item">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold small">{{ item.details.name }}</div>
|
||||
<div class="text-muted small">{{ item.details.category || 'No category' }}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="editItem(index)">
|
||||
<b-icon-pencil></b-icon-pencil>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
@click="proceedToNext"
|
||||
:disabled="completedItems.length === 0"
|
||||
>
|
||||
Next: Complete Import ({{ completedItems.length }} items)
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep3',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentItemIndex: 0,
|
||||
currentItemDetails: this.getDefaultItemDetails(),
|
||||
completedItems: [],
|
||||
showCompleted: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
availableItems() {
|
||||
return this.payload.processed_images || this.payload.photos || [];
|
||||
},
|
||||
totalItems() {
|
||||
return this.availableItems.length;
|
||||
},
|
||||
currentItem() {
|
||||
return this.availableItems[this.currentItemIndex] || null;
|
||||
},
|
||||
itemProgressPercentage() {
|
||||
return this.totalItems > 0 ? (this.completedItems.length / this.totalItems) * 100 : 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load existing completed items
|
||||
if (this.payload.completed_items) {
|
||||
this.completedItems = [...this.payload.completed_items];
|
||||
}
|
||||
|
||||
// Load current item details if resuming
|
||||
if (this.payload.current_item_details) {
|
||||
this.currentItemDetails = { ...this.payload.current_item_details };
|
||||
}
|
||||
|
||||
// Load current item index if resuming
|
||||
if (this.payload.current_item_index !== undefined) {
|
||||
this.currentItemIndex = this.payload.current_item_index;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDefaultItemDetails() {
|
||||
return {
|
||||
name: '',
|
||||
category: '',
|
||||
quantity: 1,
|
||||
unit: 'piece',
|
||||
condition: 'good',
|
||||
description: '',
|
||||
location: '',
|
||||
purchase_price: null,
|
||||
estimated_value: null
|
||||
};
|
||||
},
|
||||
|
||||
saveCurrentItem() {
|
||||
if (!this.currentItemDetails.name.trim()) {
|
||||
alert('Please enter an item name before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
const itemData = {
|
||||
image: this.currentItem,
|
||||
details: { ...this.currentItemDetails },
|
||||
saved_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Check if we're editing an existing item
|
||||
const existingIndex = this.completedItems.findIndex(item =>
|
||||
item.image === this.currentItem
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.completedItems.splice(existingIndex, 1, itemData);
|
||||
} else {
|
||||
this.completedItems.push(itemData);
|
||||
}
|
||||
|
||||
this.nextItem();
|
||||
this.updatePayload();
|
||||
},
|
||||
|
||||
skipCurrentItem() {
|
||||
this.nextItem();
|
||||
},
|
||||
|
||||
nextItem() {
|
||||
if (this.currentItemIndex < this.totalItems - 1) {
|
||||
this.currentItemIndex++;
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
},
|
||||
|
||||
previousItem() {
|
||||
if (this.currentItemIndex > 0) {
|
||||
this.currentItemIndex--;
|
||||
|
||||
// Load details if this item was already completed
|
||||
const existingItem = this.completedItems.find(item =>
|
||||
item.image === this.currentItem
|
||||
);
|
||||
|
||||
if (existingItem) {
|
||||
this.currentItemDetails = { ...existingItem.details };
|
||||
} else {
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
editItem(index) {
|
||||
const item = this.completedItems[index];
|
||||
// Find the item index in available items
|
||||
const itemIndex = this.availableItems.findIndex(img => img === item.image);
|
||||
if (itemIndex >= 0) {
|
||||
this.currentItemIndex = itemIndex;
|
||||
this.currentItemDetails = { ...item.details };
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
completed_items: this.completedItems,
|
||||
current_item_details: this.currentItemDetails,
|
||||
current_item_index: this.currentItemIndex
|
||||
});
|
||||
},
|
||||
|
||||
proceedToNext() {
|
||||
if (this.completedItems.length === 0) {
|
||||
alert('Please complete at least one item before proceeding.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePayload();
|
||||
this.$emit('next');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-image {
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.completed-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item-navigation {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
376
frontend/src/components/workflow/steps/FotoFirstStep4.vue
Normal file
376
frontend/src/components/workflow/steps/FotoFirstStep4.vue
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<template>
|
||||
<div class="foto-first-step-4">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Import Completion</h4>
|
||||
<p class="text-muted">Review and finalize your imported items.</p>
|
||||
</div>
|
||||
|
||||
<!-- Import Summary -->
|
||||
<div class="import-summary mb-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-primary mb-2">{{ totalItems }}</h3>
|
||||
<p class="card-text text-muted mb-0">Items Imported</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-success mb-2">{{ categorizedItems }}</h3>
|
||||
<p class="card-text text-muted mb-0">With Categories</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-info mb-2">{{ itemsWithLocation }}</h3>
|
||||
<p class="card-text text-muted mb-0">With Locations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h3 class="text-warning mb-2">${{ totalValue }}</h3>
|
||||
<p class="card-text text-muted mb-0">Total Value</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Breakdown -->
|
||||
<div class="category-breakdown mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Items by Category</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
|
||||
<div v-for="(count, category) in categoryBreakdown" :key="category" class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
|
||||
<span class="badge bg-secondary">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted text-center py-3">
|
||||
No items to categorize
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Options -->
|
||||
<div class="import-options mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Import Options</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="generateQr"
|
||||
v-model="importOptions.generate_qr_codes"
|
||||
>
|
||||
<label class="form-check-label" for="generateQr">
|
||||
Generate QR codes for items
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="sendNotification"
|
||||
v-model="importOptions.send_notification"
|
||||
>
|
||||
<label class="form-check-label" for="sendNotification">
|
||||
Send completion notification
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="createReport"
|
||||
v-model="importOptions.create_report"
|
||||
>
|
||||
<label class="form-check-label" for="createReport">
|
||||
Create import report
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="autoBackup"
|
||||
v-model="importOptions.auto_backup"
|
||||
>
|
||||
<label class="form-check-label" for="autoBackup">
|
||||
Auto-backup imported data
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item List -->
|
||||
<div class="item-list mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Imported Items</h6>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
class="btn"
|
||||
:class="viewMode === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:class="viewMode === 'list' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Grid View -->
|
||||
<div v-if="viewMode === 'grid'" class="row">
|
||||
<div v-for="(item, index) in items" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card h-100">
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="card-img-top item-thumb" :alt="item.details.name">
|
||||
<div class="card-body p-2">
|
||||
<h6 class="card-title mb-1">{{ item.details.name }}</h6>
|
||||
<p class="card-text small text-muted mb-1">{{ item.details.category || 'No category' }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Qty: {{ item.details.quantity }}</small>
|
||||
<small v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>Quantity</th>
|
||||
<th>Location</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in items" :key="index">
|
||||
<td>
|
||||
<img :src="item.image.processedUrl || item.image.preview" class="list-item-thumb" :alt="item.details.name">
|
||||
</td>
|
||||
<td class="fw-bold">{{ item.details.name }}</td>
|
||||
<td>
|
||||
<span v-if="item.details.category" class="badge bg-light text-dark">{{ item.details.category }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>{{ item.details.quantity }} {{ item.details.unit }}</td>
|
||||
<td>{{ item.details.location || '-' }}</td>
|
||||
<td>
|
||||
<span v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Action -->
|
||||
<div class="final-action text-center">
|
||||
<div class="card">
|
||||
<div class="card-body py-4">
|
||||
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
|
||||
<h5 class="mb-3">Ready to Complete Import</h5>
|
||||
<p class="text-muted mb-4">
|
||||
All {{ totalItems }} items have been processed and are ready to be added to your inventory.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="d-flex justify-content-center gap-3">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success btn-lg"
|
||||
@click="completeImport"
|
||||
:disabled="importing"
|
||||
>
|
||||
<div v-if="importing" class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<b-icon-check-circle v-else class="me-2"></b-icon-check-circle>
|
||||
{{ importing ? 'Importing...' : 'Complete Import' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstStep4',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
importing: false,
|
||||
viewMode: 'grid',
|
||||
importOptions: {
|
||||
generate_qr_codes: true,
|
||||
send_notification: true,
|
||||
create_report: true,
|
||||
auto_backup: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return this.payload.completed_items || [];
|
||||
},
|
||||
totalItems() {
|
||||
return this.items.length;
|
||||
},
|
||||
categorizedItems() {
|
||||
return this.items.filter(item => item.details.category).length;
|
||||
},
|
||||
itemsWithLocation() {
|
||||
return this.items.filter(item => item.details.location).length;
|
||||
},
|
||||
totalValue() {
|
||||
return this.items.reduce((sum, item) => {
|
||||
return sum + (item.details.estimated_value || 0);
|
||||
}, 0).toFixed(2);
|
||||
},
|
||||
categoryBreakdown() {
|
||||
const breakdown = {};
|
||||
this.items.forEach(item => {
|
||||
const category = item.details.category || 'uncategorized';
|
||||
breakdown[category] = (breakdown[category] || 0) + 1;
|
||||
});
|
||||
return breakdown;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Load import options from payload
|
||||
if (this.payload.import_options) {
|
||||
this.importOptions = { ...this.importOptions, ...this.payload.import_options };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async completeImport() {
|
||||
if (this.totalItems === 0) {
|
||||
alert('No items to import. Please go back and add items.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to import ${this.totalItems} items? This action cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
this.importing = true;
|
||||
|
||||
// Update payload with final options
|
||||
this.updatePayload();
|
||||
|
||||
// Simulate import process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Complete the workflow
|
||||
this.$emit('update', {
|
||||
import_completed: true,
|
||||
completion_timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Navigate to success or trigger workflow completion
|
||||
this.$emit('complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error completing import:', error);
|
||||
alert('Error completing import. Please try again.');
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
|
||||
updatePayload() {
|
||||
this.$emit('update', {
|
||||
import_options: this.importOptions,
|
||||
final_summary: {
|
||||
total_items: this.totalItems,
|
||||
categorized_items: this.categorizedItems,
|
||||
items_with_location: this.itemsWithLocation,
|
||||
total_value: parseFloat(this.totalValue),
|
||||
category_breakdown: this.categoryBreakdown
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.list-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.final-action .card {
|
||||
border: 2px solid #28a745;
|
||||
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -28,6 +28,7 @@ import Privacy from '@/views/settings/Privacy.vue';
|
|||
import Email from '@/views/settings/Email.vue';
|
||||
import Notifications from '@/views/settings/Notifications.vue';
|
||||
import Data from '@/views/settings/Data.vue';
|
||||
import Preferences from '@/views/settings/Preferences.vue';
|
||||
|
||||
|
||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||
|
|
@ -86,6 +87,8 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
path: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
|
||||
}]
|
||||
}, {
|
||||
path: '/storage-location',
|
||||
|
|
|
|||
|
|
@ -7,12 +7,65 @@ import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
|
|||
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
||||
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
||||
|
||||
const defaultPreferenceDefinitions = [
|
||||
{
|
||||
key: 'ui.compact_mode',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
label: 'Compact mode',
|
||||
description: 'Show denser item rows and reduce spacing in lists.'
|
||||
},
|
||||
{
|
||||
key: 'ui.default_search_scope',
|
||||
type: 'enum',
|
||||
options: ['inventory', 'friends', 'all'],
|
||||
default: 'inventory',
|
||||
label: 'Default search scope',
|
||||
description: 'Choose where the global search starts.'
|
||||
},
|
||||
{
|
||||
key: 'notifications.desktop_enabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
label: 'Desktop notifications',
|
||||
description: 'Enable in-browser notifications for important updates.'
|
||||
},
|
||||
{
|
||||
key: 'files.max_upload_mb',
|
||||
type: 'integer',
|
||||
default: 25,
|
||||
label: 'Default upload size limit (MB)',
|
||||
description: 'Used as a prefill hint in upload dialogs.'
|
||||
},
|
||||
{
|
||||
key: 'ui.experimental_flags',
|
||||
type: 'json',
|
||||
default: {},
|
||||
label: 'Experimental flags',
|
||||
description: 'Optional JSON toggles for feature previews.'
|
||||
},
|
||||
]
|
||||
|
||||
const parseStoredPreferences = (storageKey) => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
local_loaded: false,
|
||||
last_load: {},
|
||||
user: null,
|
||||
user_profile: null,
|
||||
token: null,
|
||||
keypair: null,
|
||||
remember: false,
|
||||
|
|
@ -31,6 +84,10 @@ export default createStore({
|
|||
domains: [],
|
||||
storage_locations: [],
|
||||
active_workflows: [],
|
||||
preferenceDefinitions: [],
|
||||
accountPreferences: {},
|
||||
devicePreferences: {},
|
||||
preferencesLoaded: false,
|
||||
},
|
||||
mutations: {
|
||||
setInventoryItems(state, {url, items}) {
|
||||
|
|
@ -69,11 +126,42 @@ export default createStore({
|
|||
setActiveWorkflows(state, workflows) {
|
||||
state.active_workflows = workflows;
|
||||
},
|
||||
setPreferenceDefinitions(state, definitions) {
|
||||
state.preferenceDefinitions = definitions;
|
||||
},
|
||||
setAccountPreferences(state, preferences) {
|
||||
state.accountPreferences = preferences;
|
||||
},
|
||||
setDevicePreferences(state, preferences) {
|
||||
state.devicePreferences = preferences;
|
||||
},
|
||||
setAccountPreference(state, {key, value}) {
|
||||
state.accountPreferences = {...state.accountPreferences, [key]: value};
|
||||
},
|
||||
setDevicePreference(state, {key, value}) {
|
||||
state.devicePreferences = {...state.devicePreferences, [key]: value};
|
||||
},
|
||||
deleteAccountPreference(state, key) {
|
||||
const prefs = {...state.accountPreferences};
|
||||
delete prefs[key];
|
||||
state.accountPreferences = prefs;
|
||||
},
|
||||
deleteDevicePreference(state, key) {
|
||||
const prefs = {...state.devicePreferences};
|
||||
delete prefs[key];
|
||||
state.devicePreferences = prefs;
|
||||
},
|
||||
setPreferencesLoaded(state, loaded) {
|
||||
state.preferencesLoaded = loaded;
|
||||
},
|
||||
setUser(state, user) {
|
||||
state.user = user;
|
||||
if (state.remember)
|
||||
localStorage.setItem('user', user);
|
||||
},
|
||||
setUserProfile(state, profile) {
|
||||
state.user_profile = profile;
|
||||
},
|
||||
setToken(state, token) {
|
||||
state.token = token;
|
||||
if (state.remember)
|
||||
|
|
@ -110,6 +198,7 @@ export default createStore({
|
|||
},
|
||||
logout(state) {
|
||||
state.user = null;
|
||||
state.user_profile = null;
|
||||
state.token = null;
|
||||
state.keypair = null;
|
||||
localStorage.removeItem('user');
|
||||
|
|
@ -136,6 +225,33 @@ export default createStore({
|
|||
}
|
||||
},
|
||||
actions: {
|
||||
async loadUserPreferences({state, commit}) {
|
||||
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
|
||||
const deviceKey = 'toolshed.preferences.device'
|
||||
|
||||
commit('setPreferenceDefinitions', defaultPreferenceDefinitions)
|
||||
commit('setAccountPreferences', parseStoredPreferences(accountKey))
|
||||
commit('setDevicePreferences', parseStoredPreferences(deviceKey))
|
||||
commit('setPreferencesLoaded', true)
|
||||
},
|
||||
async setAccountPreference({state, commit}, {key, value}) {
|
||||
commit('setAccountPreference', {key, value})
|
||||
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
|
||||
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
|
||||
},
|
||||
async resetAccountPreference({state, commit}, key) {
|
||||
commit('deleteAccountPreference', key)
|
||||
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
|
||||
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
|
||||
},
|
||||
async setDevicePreference({state, commit}, {key, value}) {
|
||||
commit('setDevicePreference', {key, value})
|
||||
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
|
||||
},
|
||||
async resetDevicePreference({state, commit}, key) {
|
||||
commit('deleteDevicePreference', key)
|
||||
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
|
||||
},
|
||||
async login({commit, dispatch, state, getters}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
|
|
@ -146,11 +262,33 @@ export default createStore({
|
|||
commit('setKey', data.key);
|
||||
const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
commit('setHomeServers', s)
|
||||
await dispatch('fetchUserProfile', {force: true})
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) {
|
||||
if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) {
|
||||
return state.user_profile
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/auth/user/')
|
||||
commit('setUserProfile', data)
|
||||
state.last_load.user_profile = Date.now()
|
||||
return data
|
||||
},
|
||||
async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null}
|
||||
const data = await servers.patch(getters.signAuth, '/auth/user/', payload)
|
||||
commit('setUserProfile', data)
|
||||
state.last_load.user_profile = Date.now()
|
||||
if (data.profile_picture) {
|
||||
state.last_load.files = 0
|
||||
}
|
||||
return data
|
||||
},
|
||||
async lookupServer({state}, {username}) {
|
||||
const domain = username.split('@')[1]
|
||||
const request = '_toolshed-server._tcp.' + domain + '.'
|
||||
|
|
@ -510,5 +648,18 @@ export default createStore({
|
|||
time: Date.now() - 1000 * 60 * 60 * 24
|
||||
}]
|
||||
},
|
||||
getPreference: (state) => (key, fallbackDefault = null) => {
|
||||
if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) {
|
||||
return state.devicePreferences[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) {
|
||||
return state.accountPreferences[key]
|
||||
}
|
||||
const definition = state.preferenceDefinitions.find((pref) => pref.key === key)
|
||||
if (definition) {
|
||||
return definition.default
|
||||
}
|
||||
return fallbackDefault
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
@ -109,16 +109,21 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapGetters(["inventory_items", "loaded_items"]),
|
||||
...mapState(["user"]),
|
||||
...mapState(["user", "storage_locations"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem"]),
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]),
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
locationPath(item) {
|
||||
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
|
||||
return loc ? loc.path : null
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
await this.fetchStorageLocations()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
|
|
@ -75,15 +75,20 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
...mapGetters(["loaded_items"]),
|
||||
...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"]),
|
||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInventoryItems()
|
||||
await this.fetchStorageLocations()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
<label for="storage_location" class="form-label">Storage Location</label>
|
||||
<select class="form-select" id="storage_location" name="storage_location"
|
||||
v-model="item.storage_location">
|
||||
<option value="">No storage location</option>
|
||||
<option :value="null">No storage location</option>
|
||||
<option v-for="location in storage_locations" :value="location.id"
|
||||
:key="location.id">
|
||||
{{ location.path }}
|
||||
|
|
@ -103,12 +103,13 @@ export default {
|
|||
owned_quantity: 0,
|
||||
image: "",
|
||||
files: [],
|
||||
storage_location: null,
|
||||
...this.inventory_items.find(item => item.id === parseInt(this.id))
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo"]),
|
||||
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
||||
changeFiles(files) {
|
||||
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
|
||||
},
|
||||
|
|
@ -116,6 +117,7 @@ export default {
|
|||
async mounted() {
|
||||
await this.fetchInfo();
|
||||
await this.fetchInventoryItems();
|
||||
await this.fetchStorageLocations();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@
|
|||
<div class="mb-3">
|
||||
<label for="storage_location" class="form-label">Storage Location</label>
|
||||
<select class="form-select" id="storage_location" name="storage_location"
|
||||
v-model="item.storage_location_id">
|
||||
<option value="">No storage location</option>
|
||||
v-model="item.storage_location">
|
||||
<option :value="null">No storage location</option>
|
||||
<option v-for="location in storage_locations" :value="location.id"
|
||||
:key="location.id">
|
||||
{{ location.path }}
|
||||
|
|
@ -95,18 +95,20 @@ export default {
|
|||
image: "",
|
||||
tags: [],
|
||||
properties: [],
|
||||
files: []
|
||||
files: [],
|
||||
storage_location: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['createInventoryItem', 'fetchInfo'])
|
||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations'])
|
||||
},
|
||||
computed: {
|
||||
...mapState(["availability_policies", "storage_locations"]),
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInfo();
|
||||
await this.fetchStorageLocations();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@
|
|||
<h5 class="card-title mb-0">Profile Details</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img src="/assets/img/avatars/avatar.png"
|
||||
alt="Christina Mason" class="img-fluid rounded-circle mb-2" width="128"
|
||||
height="128"/>
|
||||
<authenticated-image :src="profilePictureSrc" :owner="userHandle"
|
||||
:alt="profile.username || userHandle"
|
||||
:title="profile.username || userHandle"
|
||||
img-class="img-fluid rounded-circle mb-2"
|
||||
:width="128" :height="128" fit="cover"/>
|
||||
<h5 class="card-title mb-0">
|
||||
{{ user.username }}
|
||||
{{ profile.username }}
|
||||
</h5>
|
||||
<div class="text-muted mb-2">
|
||||
{{ user.email }}
|
||||
{{ profile.email }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -28,12 +30,12 @@
|
|||
data-feather="message-square"></span> Message</a>
|
||||
</div>
|
||||
</div>
|
||||
<!--{% if user.bio %}-->
|
||||
<!--{% if profile.bio %}-->
|
||||
<hr class="my-0"/>
|
||||
<div class="card-body">
|
||||
<h5 class="h6 card-title">Bio</h5>
|
||||
<div class="text-muted mb-2">
|
||||
{{ user.bio }}
|
||||
{{ profile.bio }}
|
||||
</div>
|
||||
</div>
|
||||
<!--{% endif %}-->
|
||||
|
|
@ -54,9 +56,9 @@
|
|||
<div class="card-body">
|
||||
<h5 class="h6 card-title">About</h5>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<!--{% if user.location %}-->
|
||||
<!--{% if profile.location %}-->
|
||||
<li class="mb-1"><span data-feather="home" class="feather-sm mr-1"></span> Lives
|
||||
in <a href="#">{{ user.location }}</a></li>
|
||||
in <a href="#">{{ profile.location }}</a></li>
|
||||
<!--{% endif %}-->
|
||||
|
||||
<li class="mb-1"><span data-feather="briefcase" class="feather-sm mr-1"></span>
|
||||
|
|
@ -230,24 +232,33 @@
|
|||
|
||||
<script>
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'Profile',
|
||||
data() {
|
||||
return {
|
||||
user: {
|
||||
name: 'John Doe',
|
||||
avatar: '/static/assets/img/avatars/avatar.png',
|
||||
cover: '/static/assets/img/photos/unsplash-1.jpg',
|
||||
occupation: 'Frontend Developer',
|
||||
company: 'Facebook Inc.',
|
||||
email: 'foo@bar.com',
|
||||
phone: '+12 345 678 001',
|
||||
address: 'Boulevard of Broken Dreams, 1234',
|
||||
}
|
||||
components: {BaseLayout, AuthenticatedImage},
|
||||
computed: {
|
||||
...mapState(['user', 'user_profile']),
|
||||
userHandle() {
|
||||
return this.user;
|
||||
},
|
||||
profile() {
|
||||
return this.user_profile || {
|
||||
username: this.user,
|
||||
email: ''
|
||||
};
|
||||
},
|
||||
profilePictureSrc() {
|
||||
return this.user_profile?.profile_picture?.name || null;
|
||||
}
|
||||
},
|
||||
components: {BaseLayout},
|
||||
methods: {
|
||||
...mapActions(['fetchUserProfile'])
|
||||
},
|
||||
mounted() {
|
||||
this.fetchUserProfile();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@
|
|||
<router-link class="list-group-item list-group-item-action"
|
||||
:to="{name: 'notifications'}" active-class="active">Web notifications
|
||||
</router-link>
|
||||
<router-link class="list-group-item list-group-item-action" :to="{name: 'preferences'}"
|
||||
active-class="active">Preferences
|
||||
</router-link>
|
||||
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
|
||||
active-class="active">Your data
|
||||
</router-link>
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
|||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
import { workflowRegistry } from '@/workflows.js';
|
||||
import { getStepComponent, hasStepComponent } from '@/components/workflow/ComponentRegistry.js';
|
||||
|
||||
export default {
|
||||
name: 'WorkflowDetail',
|
||||
|
|
@ -256,15 +257,11 @@ export default {
|
|||
},
|
||||
|
||||
stepComponent() {
|
||||
// Return step-specific component if it exists
|
||||
// This allows for custom UI per workflow type and step
|
||||
// Return step-specific component if it exists using the component registry
|
||||
const workflowType = this.workflowInstance?.workflow_type;
|
||||
if (workflowType) {
|
||||
// Try to load a step-specific component
|
||||
// e.g., FotoFirstImportStep1, BulkImportStep2, etc.
|
||||
const componentName = `${workflowType}Step${this.currentStep}`;
|
||||
// This would require registering step components
|
||||
return null; // For now, use default content
|
||||
console.log('Determining step component for workflow type:', this.workflowInstance, 'and step:', this.currentStep);
|
||||
if (workflowType && this.currentStep) {
|
||||
return getStepComponent(workflowType, this.currentStep);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
|
@ -282,6 +279,19 @@ export default {
|
|||
canNavigatePrev() {
|
||||
// Check if we can navigate to the previous step
|
||||
return this.currentStepIndex > 0;
|
||||
},
|
||||
|
||||
hasCustomComponent() {
|
||||
// Check if the current step has a custom component defined
|
||||
return this.stepComponent !== null;
|
||||
},
|
||||
|
||||
componentStatusClass() {
|
||||
return this.hasCustomComponent ? 'bg-light border-success' : 'bg-light border-muted';
|
||||
},
|
||||
|
||||
componentStatusText() {
|
||||
return this.hasCustomComponent ? 'Using custom component' : 'Using default view';
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -453,6 +463,12 @@ export default {
|
|||
formatDateTime(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
hasStepComponent(stepNumber) {
|
||||
// Check if a specific step has a custom component available
|
||||
const workflowType = this.workflowInstance?.workflow_type;
|
||||
return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -498,4 +514,3 @@ export default {
|
|||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,18 @@
|
|||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<img alt="Charles Hall"
|
||||
src="/assets/img/avatars/avatar.png"
|
||||
class="rounded-circle img-responsive mt-2" width="128"
|
||||
height="128"/>
|
||||
<authenticated-image :src="profilePictureSrc" :owner="user"
|
||||
:refresh-key="profilePictureRefreshKey"
|
||||
:alt="user" :title="user"
|
||||
img-class="rounded-circle img-responsive mt-2"
|
||||
:width="128" :height="128" fit="cover"/>
|
||||
<div class="mt-2">
|
||||
<span class="btn btn-primary"><i
|
||||
class="fas fa-upload"></i> Upload</span>
|
||||
<fs-file-source @input="uploadPicture">
|
||||
<span class="btn btn-primary"><i class="fas fa-upload"></i> Upload</span>
|
||||
</fs-file-source>
|
||||
<button class="btn btn-outline-secondary ml-2" type="button" @click="removePicture">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<small>For best results, use an image at least 128px by
|
||||
128px in .jpg format</small>
|
||||
|
|
@ -99,10 +104,38 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
|
||||
export default {
|
||||
name: 'Account',
|
||||
components: {}
|
||||
components: {AuthenticatedImage, FsFileSource},
|
||||
computed: {
|
||||
...mapState(['user', 'user_profile']),
|
||||
profilePictureSrc() {
|
||||
return this.user_profile?.profile_picture?.name || null;
|
||||
},
|
||||
profilePictureRefreshKey() {
|
||||
return this.user_profile?.profile_picture?.id || 'none';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchUserProfile', 'updateUserProfilePicture']),
|
||||
async uploadPicture(files) {
|
||||
const image = files.find(file => file.mime_type?.startsWith('image/'))
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
await this.updateUserProfilePicture({file: image});
|
||||
},
|
||||
async removePicture() {
|
||||
await this.updateUserProfilePicture({file: null});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchUserProfile();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
234
frontend/src/views/settings/Preferences.vue
Normal file
234
frontend/src/views/settings/Preferences.vue
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Preferences</h5>
|
||||
|
||||
<div v-if="!preferencesLoaded" class="text-center py-4 text-muted">
|
||||
Loading preferences...
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 26%">Preference</th>
|
||||
<th style="width: 17%">Device</th>
|
||||
<th style="width: 17%">Account</th>
|
||||
<th style="width: 17%">Default</th>
|
||||
<th style="width: 23%">Effective</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="schema in schemas" :key="schema.key">
|
||||
<td>
|
||||
<div class="font-weight-bold">{{ schema.label }}</div>
|
||||
<small v-if="schema.description" class="text-muted">{{ schema.description }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<PreferenceInput
|
||||
:id="`device-${schema.key}`"
|
||||
:schema="schema"
|
||||
:value="effectiveDeviceValues[schema.key]"
|
||||
@input="updateDevicePreference(schema.key, $event)"
|
||||
@clear="clearDevicePreference(schema.key)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<PreferenceInput
|
||||
:id="`account-${schema.key}`"
|
||||
:schema="schema"
|
||||
:value="effectiveAccountValues[schema.key]"
|
||||
@input="updateAccountPreference(schema.key, $event)"
|
||||
@clear="clearAccountPreference(schema.key)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<PreferenceInput
|
||||
:id="`default-${schema.key}`"
|
||||
:schema="schema"
|
||||
:value="schema.default"
|
||||
:disabled="true"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-weight-bold">
|
||||
{{ formatEffectiveValue(schema, getPreference(schema.key)) }}
|
||||
<span
|
||||
v-if="hasChanges && formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) !== formatEffectiveValue(schema, getPreference(schema.key))"
|
||||
class="text-warning ml-1"
|
||||
>
|
||||
-> {{ formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) }}
|
||||
</span>
|
||||
</div>
|
||||
<small class="text-muted">Source: {{ getPreferenceSource(schema.key) }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex pt-3 border-top mt-3">
|
||||
<button @click="savePreferences" :disabled="!hasChanges || saving" class="btn btn-primary mr-2">
|
||||
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||
</button>
|
||||
<button @click="reloadPreferences" :disabled="!hasChanges || saving" class="btn btn-secondary">
|
||||
Discard changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapState} from 'vuex'
|
||||
import PreferenceInput from '@/components/inputs/PreferenceInput.vue'
|
||||
|
||||
export default {
|
||||
name: 'Preferences',
|
||||
components: {PreferenceInput},
|
||||
data() {
|
||||
return {
|
||||
saving: false,
|
||||
accountChanges: {},
|
||||
deviceChanges: {},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['preferencesLoaded', 'preferenceDefinitions', 'devicePreferences', 'accountPreferences']),
|
||||
...mapGetters(['getPreference']),
|
||||
schemas() {
|
||||
return this.preferenceDefinitions.map((pref) => ({
|
||||
...pref,
|
||||
label: pref.label || this.formatLabel(pref.key),
|
||||
description: pref.description || '',
|
||||
}))
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.accountChanges).length > 0 || Object.keys(this.deviceChanges).length > 0
|
||||
},
|
||||
effectiveDeviceValues() {
|
||||
return {...this.devicePreferences, ...this.deviceChanges}
|
||||
},
|
||||
effectiveAccountValues() {
|
||||
return {...this.accountPreferences, ...this.accountChanges}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions([
|
||||
'loadUserPreferences',
|
||||
'setAccountPreference',
|
||||
'resetAccountPreference',
|
||||
'setDevicePreference',
|
||||
'resetDevicePreference',
|
||||
]),
|
||||
formatLabel(key) {
|
||||
const parts = key.split('.')
|
||||
const name = parts[parts.length - 1] || ''
|
||||
return name
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
},
|
||||
getEffectiveValueWithPendingChanges(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
|
||||
if (this.deviceChanges[key] !== null) {
|
||||
return this.deviceChanges[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key) && this.accountChanges[key] !== null) {
|
||||
return this.accountChanges[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
|
||||
return this.accountPreferences[key]
|
||||
}
|
||||
} else if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
|
||||
if (this.accountChanges[key] !== null) {
|
||||
return this.accountChanges[key]
|
||||
}
|
||||
} else {
|
||||
return this.getPreference(key)
|
||||
}
|
||||
|
||||
const definition = this.preferenceDefinitions.find((p) => p.key === key)
|
||||
return definition ? definition.default : null
|
||||
},
|
||||
getPreferenceSource(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
|
||||
return this.deviceChanges[key] !== null
|
||||
? 'Device'
|
||||
: Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null
|
||||
? 'Account'
|
||||
: 'Default'
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
|
||||
return this.accountChanges[key] !== null ? 'Account' : 'Default'
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this.devicePreferences, key) && this.devicePreferences[key] !== null) {
|
||||
return 'Device'
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
|
||||
return 'Account'
|
||||
}
|
||||
return 'Default'
|
||||
},
|
||||
updateDevicePreference(key, value) {
|
||||
this.deviceChanges = {...this.deviceChanges, [key]: value}
|
||||
},
|
||||
updateAccountPreference(key, value) {
|
||||
this.accountChanges = {...this.accountChanges, [key]: value}
|
||||
},
|
||||
clearDevicePreference(key) {
|
||||
this.deviceChanges = {...this.deviceChanges, [key]: null}
|
||||
},
|
||||
clearAccountPreference(key) {
|
||||
this.accountChanges = {...this.accountChanges, [key]: null}
|
||||
},
|
||||
reloadPreferences() {
|
||||
this.accountChanges = {}
|
||||
this.deviceChanges = {}
|
||||
},
|
||||
formatEffectiveValue(schema, value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '(not set)'
|
||||
}
|
||||
if (schema.type === 'boolean') {
|
||||
return value ? 'Yes' : 'No'
|
||||
}
|
||||
if (schema.type === 'json') {
|
||||
return typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
return String(value)
|
||||
},
|
||||
async savePreferences() {
|
||||
this.saving = true
|
||||
try {
|
||||
for (const [key, value] of Object.entries(this.accountChanges)) {
|
||||
if (value === null) {
|
||||
await this.resetAccountPreference(key)
|
||||
} else {
|
||||
await this.setAccountPreference({key, value})
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(this.deviceChanges)) {
|
||||
if (value === null) {
|
||||
await this.resetDevicePreference(key)
|
||||
} else {
|
||||
await this.setDevicePreference({key, value})
|
||||
}
|
||||
}
|
||||
this.accountChanges = {}
|
||||
this.deviceChanges = {}
|
||||
} catch (error) {
|
||||
console.error('Error saving preferences:', error)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadUserPreferences()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue