Files
gyoza2/src/pages/StockItem.vue
2026-07-26 00:23:28 +02:00

676 lines
21 KiB
Vue

<template>
<q-page class="q-pa-lg item-page">
<q-btn flat round icon="o_arrow_back" class="q-mb-md" @click="$router.back()" />
<div class="item-header row items-center q-mb-md">
<div class="col">
<div class="text-h4 text-weight-bold" :class="{'text-faded': !item.name}">
{{ item.name || 'Sans nom' }}
</div>
<div class="row items-center q-mt-xs q-gutter-x-sm">
<q-chip dense square color="primary" text-color="white" class="text-weight-medium">
{{ item.ref }}
</q-chip>
<q-chip dense square outline color="grey-5">
ID {{ item.short_id }}
</q-chip>
<span class="text-grey-5 text-body2">{{ item.type }}</span>
</div>
</div>
<q-btn flat round size="lg" icon="o_qr_code" @click="generateQRCode">
<q-tooltip>QR Code</q-tooltip>
</q-btn>
</div>
<div class="status-bar row q-gutter-sm q-mb-lg">
<q-chip :color="stateColor(item.state)" text-color="white" icon="o_palette" size="md" class="status-chip">
{{ item.state || 'État inconnu' }}
</q-chip>
<q-chip :color="item.working === true ? 'green' : item.working === false ? 'red' : 'grey-6'"
text-color="white"
:icon="item.working === true ? 'o_check_circle' : item.working === false ? 'o_cancel' : 'o_help'"
size="md"
class="status-chip">
{{ item.working === true ? 'Fonctionnel' : item.working === false ? 'H.S.' : 'Inconnu' }}
</q-chip>
<q-chip :color="item.available ? 'green' : 'red'"
text-color="white"
:icon="item.available ? 'o_inventory_2' : 'o_remove_shopping_cart'"
size="md"
class="status-chip">
{{ item.available ? 'En stock' : 'Absent' }}
</q-chip>
<q-chip v-if="item.deleted" color="red" text-color="white" icon="o_delete" size="md" class="status-chip">
Supprimé
</q-chip>
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-7 col-lg-8">
<q-card flat class="info-card">
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium text-primary">Informations générales</div>
</q-card-section>
<q-card-section>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<q-input v-model="item.name"
:disable="item.deleted"
filled
label="Nom"
color="primary"
@update:model-value="changed = true" />
</div>
<div class="col-12 col-sm-6">
<q-input v-model="item.owner"
:disable="item.deleted"
filled
label="Propriétaire"
color="primary"
@update:model-value="changed = true" />
</div>
</div>
<q-input v-model="item.comment"
:disable="item.deleted"
filled
type="textarea"
rows="3"
label="Commentaire"
color="primary"
class="q-mt-md"
@update:model-value="changed = true" />
</q-card-section>
</q-card>
<q-card flat class="info-card q-mt-md">
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium text-primary">État</div>
</q-card-section>
<q-card-section>
<div class="row items-center q-mb-md">
<span class="text-caption text-grey-5 q-mr-md" style="min-width:100px">Cosmétique</span>
<q-btn-toggle
no-caps
spread
:disable="item.deleted"
v-model="item.state"
@update:model-value="changed = true"
toggle-color="primary"
text-color="white"
:options="stateOptions"
class="state-toggle"
/>
</div>
<div class="row items-center q-mb-md">
<span class="text-caption text-grey-5 q-mr-md" style="min-width:100px">Fonctionnel</span>
<q-btn-toggle
no-caps
:disable="item.deleted"
v-model="item.working"
@update:model-value="changed = true"
toggle-color="primary"
text-color="white"
:options="[
{label: 'Oui', value: true},
{label: '?', value: null},
{label: 'Non', value: false}
]"
class="status-toggle"
/>
</div>
<div class="row items-center q-mb-md">
<span class="text-caption text-grey-5 q-mr-md" style="min-width:100px">En stock</span>
<q-btn-toggle
no-caps
:disable="item.deleted"
v-model="item.available"
@update:model-value="changed = true"
toggle-color="primary"
text-color="white"
:options="[
{label: 'Oui', value: true},
{label: 'Non', value: false}
]"
class="status-toggle"
/>
</div>
<div class="row items-center">
<span class="text-caption text-grey-5 q-mr-md" style="min-width:100px">Supprimé</span>
<q-btn-toggle
no-caps
v-model="item.deleted"
@update:model-value="changed = true"
toggle-color="primary"
text-color="white"
:options="[
{label: 'Oui', value: true},
{label: 'Non', value: false}
]"
class="status-toggle"
/>
</div>
</q-card-section>
</q-card>
<div class="fixed-bottom-right q-pa-lg save-bar" v-if="changed">
<q-btn color="primary"
icon="o_save"
label="Enregistrer"
:disable="originalItem.deleted"
@click="updateItem()"
class="save-btn" />
</div>
</div>
<div class="col-12 col-md-5 col-lg-4">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-medium text-primary">Historique</div>
<q-space />
<q-btn flat dense round icon="o_add_circle" color="primary" size="sm" @click="openHistoryForm()">
<q-tooltip>Ajouter une entrée</q-tooltip>
</q-btn>
</div>
<div v-if="history.length === 0" class="text-grey-5 text-center q-py-xl">
<q-icon name="o_history" size="3rem" class="q-mb-sm" />
<div>Aucun historique pour cet item</div>
</div>
<div v-else class="timeline">
<div v-for="event in history" :key="event.id"
class="timeline-item"
@click="showHistory = true; selectedHistory = event">
<div class="timeline-dot" :class="event.type === 'issue' ? 'dot-issue' : 'dot-comment'">
<q-icon :name="event.type === 'issue' ? (event.solved_at ? 'o_check_circle' : 'o_error') : 'o_chat'"
size="14px" color="white" />
</div>
<div class="timeline-content">
<div class="timeline-card">
<div v-if="event.image_file" class="timeline-image">
<img :src="getImageUrl(event, event.image_file, true)" alt="" />
</div>
<div class="timeline-body">
<div class="timeline-text" v-html="event.text.split('\n').join('<br>')"></div>
<div class="timeline-meta">
<span class="text-capitalize text-weight-medium">{{ event.user }}</span>
<span class="text-grey-5">· {{ getDate(event.date || event.created) }}</span>
</div>
<div v-if="event.solved_at" class="timeline-solved">
<q-icon name="o_check" size="xs" color="green" />
Résolu par <span class="text-capitalize">{{ event.solved_by }}</span>
le {{ getDate(event.solved_at) }}
</div>
</div>
</div>
</div>
</div>
</div>
<q-btn outline color="primary"
icon="o_add"
label="Ajouter une entrée"
class="full-width q-mt-md"
@click="openHistoryForm()" />
</div>
</div>
<q-dialog v-model="showQr">
<q-card style="width: 480px">
<q-card-section class="text-center">
<div class="text-h6 q-mb-sm">{{ item.name }}</div>
<div class="text-caption text-grey-5 q-mb-md">{{ item.ref }}</div>
<q-img :src="qrCodeDataUrl" v-if="qrCodeDataUrl" alt="QR Code" style="max-height:360px;max-width:360px" class="rounded-borders" />
</q-card-section>
<q-card-actions align="center">
<q-btn flat label="Fermer" color="grey" v-close-popup />
<q-btn flat label="Imprimer" color="primary" @click="openQr" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showHistoryForm">
<q-card style="min-width:420px">
<q-card-section>
<div class="text-h6">Ajouter une entrée</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-btn-group spread outline class="full-width q-mb-md">
<q-btn :outline="pendingHistoryType !== 'comment'"
:color="pendingHistoryType === 'comment' ? 'primary' : 'grey'"
label="Commentaire"
icon="o_chat"
@click="pendingHistoryType = 'comment'" />
<q-btn :outline="pendingHistoryType !== 'issue'"
:color="pendingHistoryType === 'issue' ? 'primary' : 'grey'"
label="Tâche"
icon="o_task"
@click="pendingHistoryType = 'issue'" />
</q-btn-group>
<q-input v-model="pendingHistory"
autofocus
filled
type="textarea"
rows="3"
label="Description"
color="primary" />
<q-uploader class="q-mt-md"
:accept="'.jpg,.png'"
:factory="uploadFile"
label="Photo (optionnel)"
@added="hasFile = true"
ref="uploader"
no-thumbnails
hide-upload-btn
flat
bordered
color="primary" />
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey" @click.stop="showHistoryForm = false" />
<q-btn flat label="Ajouter" color="primary" @click.stop="addHistory()" :disable="!pendingHistory" />
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showHistory">
<q-card style="min-width:420px; max-width:560px">
<img v-if="selectedHistory.image_file"
:src="getImageUrl(selectedHistory, selectedHistory.image_file)"
alt="photo"
class="history-detail-image" />
<q-card-section>
<div class="text-body1" v-html="selectedHistory.text.split('\n').join('<br>')"></div>
<div class="q-mt-sm text-caption text-grey-5">
<q-chip dense :color="selectedHistory.type === 'issue' ? 'orange' : 'primary'"
text-color="white"
:label="selectedHistory.type === 'issue' ? 'Tâche' : 'Commentaire'"
size="sm"
class="q-mr-sm" />
<span class="text-capitalize">{{ selectedHistory.user }}</span>
<span>· {{ getDate(selectedHistory.date || selectedHistory.created) }}</span>
</div>
</q-card-section>
<q-card-section v-if="selectedHistory.type === 'issue' && !selectedHistory.solved_at">
<q-btn outline color="primary" icon="o_check" label="Marquer comme terminé"
@click="setTaskDone(selectedHistory)" />
</q-card-section>
<q-card-section v-if="selectedHistory.solved_at" class="q-pt-none">
<div class="text-caption text-green">
Résolu par <span class="text-capitalize">{{ selectedHistory.solved_by }}</span>
le {{ getDate(selectedHistory.solved_at) }}
</div>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Supprimer" color="red" @click.stop="removeHistory(selectedHistory)" />
<q-btn flat label="Fermer" color="grey" @click.stop="showHistory = false; selectedHistory = null" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script>
import { date } from 'quasar'
import imageCompression from 'browser-image-compression'
import QRCode from 'qrcode'
export default {
name: 'StockItem',
props: ['itemRef', 'shortId'],
data () {
return {
showHistoryForm: false,
pendingHistory: null,
pendingHistoryType: 'comment',
changed: false,
hasFile: false,
username: null,
qrCodeDataUrl: null,
showQr: false,
item: {},
showHistory: false,
selectedHistory: null
}
},
computed: {
stateOptions () {
return [
{ label: 'Neuf', value: 'Neuf' },
{ label: 'Excellent', value: 'Excellent' },
{ label: 'Bon', value: 'Bon' },
{ label: 'Moyen', value: 'Moyen' },
{ label: 'Abimé', value: 'Abimé' },
{ label: 'Inutilisable', value: 'Inutilisable' }
]
},
stock () {
return this.$store.state.core.firebase.stock || []
},
originalItem () {
return this.stock.find(item => {
return (item) ? item.ref === this.itemRef : false
}) || []
},
itemId () {
return this.$store.state.core.firebase.stock.findIndex(item => {
return (item) ? item.ref === this.itemRef : false
})
},
history () {
return this.$store.state.core.firebase.history.filter(item => {
return item.ref === this.itemRef
}).sort((a, b) => {
return (a.timestamp > b.timestamp) ? 1 : -1
}) || []
}
},
created: function () {
if (this.shortId) {
let itemRef = this.stock.find(item => {
return item.short_id === parseInt(this.shortId)
})?.ref
if (itemRef) this.$router.push(`/item/${itemRef}`)
}
this.loadItem()
this.$watch(
() => this.itemRef,
() => { this.loadItem() }
)
},
mounted () {
this.username = this.$q.localStorage.getItem('username')
},
methods: {
stateColor (state) {
const colors = {
Neuf: 'green-8',
Excellent: 'green',
Bon: 'lime-9',
Moyen: 'orange',
Abimé: 'red',
Inutilisable: 'grey-7'
}
return colors[state] || 'grey'
},
getDate (timestamp) {
return date.formatDate(timestamp, 'DD/MM/YY - HH:mm')
},
openHistoryForm () {
this.showHistoryForm = true
},
async addHistory () {
this.$q.loading.show()
let historyDate = new Date()
let file = null
if (this.hasFile) {
file = await this.uploadFile(this.$refs.uploader.files[0])
}
this.$store.dispatch('core/addToCollection', {
collection: 'history',
data: {
ref: this.itemRef,
text: this.pendingHistory,
type: this.pendingHistoryType,
user: this.$store.state.core.username,
image_file: file,
date: historyDate.toISOString()
}
}).then(_ => {
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.showHistoryForm = false
this.pendingHistory = null
this.hasFile = false
this.$refs.uploader.reset()
this.$q.loading.hide()
})
})
},
removeHistory (data) {
this.$q.dialog({
title: 'Supprimer ?',
message: 'Es-tu certain de vouloir supprimer cette entrée ?',
ok: 'Supprimer',
cancel: 'Annuler'
}).onOk(() => {
this.$store.dispatch('core/removeFromCollection', {
collection: 'history',
data
}).then(_ => {
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.showHistory = false
this.selectedHistory = null
this.$q.notify('Supprimé !')
})
})
})
},
updateItem () {
this.$q.loading.show()
this.$store.dispatch('core/updateItem', {
collection: 'stock',
itemId: this.itemId,
data: this.item
}).then(_ => {
this.$q.notify({
message: 'Enregistré !',
color: 'primary',
avatar: '/gyoza.svg',
timeout: 2000
})
this.changed = false
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.$q.loading.hide()
})
})
},
setTaskDone (history) {
this.$store.dispatch('core/updateItem', {
collection: 'history',
itemId: history.id,
data: {
id: history.id,
solved_at: new Date().toISOString(),
solved_by: this.username
}
}).then(_ => {
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.showHistory = false
this.selectedHistory = null
})
})
},
async uploadFile (file) {
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 2000,
useWebWorker: true
}
try {
return await imageCompression(file, options)
} catch (error) {
console.error('Error compressing image:', error)
}
},
getImageUrl (history, image, thumb = false) {
return this.$pb.files.getURL(history, image, { thumb: thumb ? '300x300f' : null })
},
loadItem () {
let item = this.stock.find(item => {
return (item) ? item.ref === this.itemRef : false
}) || []
this.item = { ...item }
},
async generateQRCode () {
this.showQr = true
try {
const url = await QRCode.toDataURL(window.location.href, {
errorCorrectionLevel: 'H',
width: 800,
margin: 1
})
this.qrCodeDataUrl = url
} catch (error) {
console.error('Error generating QR code:', error)
}
},
openQr () {
const win = window.open()
win.document.write(`<img src="${this.qrCodeDataUrl}" alt="QR Code" style="max-height:500px;max-width:500px" />`)
win.document.close()
}
}
}
</script>
<style scoped>
.item-page {
max-width: 1400px;
margin: 0 auto;
}
.item-header .text-h4 {
letter-spacing: -0.02em;
}
.status-bar {
flex-wrap: wrap;
}
.status-chip {
font-weight: 500;
border-radius: 10px;
padding: 4px 12px;
}
.info-card {
border-radius: 16px;
}
.state-toggle,
.status-toggle {
border-radius: 10px;
overflow: hidden;
}
.state-toggle :deep(.q-btn) {
min-width: 48px;
font-size: 0.75rem;
font-weight: 500;
}
.status-toggle :deep(.q-btn) {
min-width: 64px;
font-weight: 500;
}
.save-bar {
z-index: 100;
}
.save-btn {
border-radius: 14px;
padding: 8px 28px;
font-size: 1rem;
box-shadow: 0 4px 20px rgba(6, 182, 212, 0.3);
}
.timeline {
position: relative;
}
.timeline-item {
display: flex;
gap: 14px;
padding: 4px 0;
cursor: pointer;
}
.timeline-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 6px;
z-index: 1;
}
.dot-comment {
background: #06b6d4;
}
.dot-issue {
background: #f97316;
}
.timeline-item:not(:last-child) .timeline-content {
position: relative;
}
.timeline-item:not(:last-child) .timeline-content::before {
content: '';
position: absolute;
left: -28px;
top: 34px;
bottom: -10px;
width: 2px;
background: rgba(255, 255, 255, 0.08);
}
.timeline-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
flex: 1;
transition: border-color 0.2s, background 0.2s;
}
.timeline-item:hover .timeline-card {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
}
.timeline-image img {
width: 100%;
max-height: 160px;
object-fit: cover;
}
.timeline-body {
padding: 12px 14px;
}
.timeline-text {
font-size: 0.9rem;
line-height: 1.45;
margin-bottom: 6px;
}
.timeline-meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.timeline-solved {
font-size: 0.72rem;
color: #4ade80;
margin-top: 4px;
}
.history-detail-image {
width: 100%;
max-height: 300px;
object-fit: cover;
}
</style>