This commit is contained in:
2026-07-26 00:23:28 +02:00
parent 6baeb54061
commit efd78d9bd7
17 changed files with 1475 additions and 493 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -39,7 +39,7 @@ module.exports = configure(function (/* ctx */) {
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons' // optional, you are not bound to it
'material-icons-outlined' // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
@ -85,7 +85,11 @@ module.exports = configure(function (/* ctx */) {
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
config: {
dark: 'auto'
},
iconSet: 'material-icons-outlined',
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack

View File

@ -9,7 +9,7 @@
:to="`item/${history.ref}`"
>
<q-item-section avatar>
<q-icon :name="history.image ? 'image' : 'chat'"></q-icon>
<q-icon :name="history.image ? 'o_image' : 'o_chat'"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="3" label>{{ history.text }}</q-item-label>

View File

@ -1,10 +1,157 @@
// app global css in SCSS form
@font-face {
font-family: geist;
src: url(./font/Geist-var.ttf) format('truetype');
}
// declare a class which applies it
html, body {
font-family: 'geist';
font-family: 'geist', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
--glow-primary: 0 0 20px rgba(6, 182, 212, 0.15);
--glow-secondary: 0 0 20px rgba(168, 85, 247, 0.15);
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.q-drawer {
background: rgba(15, 15, 20, 0.95) !important;
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.q-header {
background: rgba(9, 9, 11, 0.95) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.q-card {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(15, 15, 20, 0.95);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.q-table__container,
.q-table {
border-radius: 12px;
overflow: hidden;
}
.q-table tbody td {
border-color: rgba(255, 255, 255, 0.04) !important;
}
.q-table thead th {
border-color: rgba(255, 255, 255, 0.06) !important;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
opacity: 0.7;
}
.q-btn {
border-radius: 10px;
font-weight: 500;
letter-spacing: 0.01em;
transition: all 0.2s ease;
}
.q-btn--outline:before {
border-radius: 10px;
}
.q-field--outlined .q-field__control {
border-radius: 10px;
border-color: rgba(255, 255, 255, 0.1);
}
.q-field--outlined .q-field__control:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.q-input .q-field__native,
.q-input .q-field__label {
font-family: 'geist', sans-serif;
}
.q-field__label {
font-weight: 400;
}
.q-chip {
border-radius: 8px;
font-weight: 500;
font-size: 0.75rem;
}
.q-item {
border-radius: 8px;
margin: 1px 0;
}
.q-item.q-router-link--active,
.q-item--active {
background: rgba(168, 85, 247, 0.1);
border-left: 2px solid #a855f7;
}
.q-dialog .q-card {
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.q-separator {
background: rgba(255, 255, 255, 0.06);
}
.q-btn-toggle .q-btn {
border-radius: 8px;
}
.q-btn-toggle {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
overflow: hidden;
}
.text-faded {
opacity: 0.4;
}
.q-page {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.q-uploader {
border-radius: 12px;
border: 1px dashed rgba(255, 255, 255, 0.15) !important;
background: rgba(255, 255, 255, 0.02) !important;
}
.q-toggle__thumb {
border-radius: 50%;
}

View File

@ -1,35 +1,13 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
$primary : #06b6d4;
$secondary : #a855f7;
$accent : #f472b6;
// Check documentation for full list of Quasar variables
$dark : #09090b;
$dark-page : #060608;
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
$positive : #10b981;
$negative : #ef4444;
$info : #60a5fa;
$warning : #f59e0b;
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
//$primary : #1976D2;
//$secondary : #26A69A;
//$accent : #9C27B0;
//
//$dark : #1D1D1D;
//$dark-page : #121212;
//
//$positive : #21BA45;
//$negative : #C10015;
//$info : #31CCEC;
//$warning : #F2C037;
$primary: #027be3;
$secondary: #26A69A;
$tertiary: #555;
$neutral: #E0E1E2;
$positive: #21BA45;
$negative: #DB2828;
$info: #31CCEC;
$warning: #F2C037;
$typography-font-family: 'Geist', sans-serif;

View File

@ -1,24 +1,24 @@
<template>
<q-layout v-if="!loading"> <!-- Be sure to play with the Layout demo on docs -->
<q-header class="no-shadow text-center bg-white">
<q-toolbar color="secondary" inverted style="border-bottom:1px solid rgba(0,0,0,0.1)">
<q-header class="no-shadow text-center">
<q-toolbar color="secondary" inverted>
<q-btn
flat
size="18px"
icon="menu"
icon="o_menu"
color="secondary"
@click="leftDrawer = !leftDrawer"
/>
<q-toolbar-title class="text-center">
<div class="q-px-md" @click="$router.push('/dashboard')" style="display:inline-block;cursor:pointer;max-width:250px">
<img src="~assets/gyoza_logo_inline.jpg" style="width:100%">
<div class="q-px-md" @click="$router.push('/dashboard')" style="display:inline-block;cursor:pointer">
<img src="~assets/gyoza.svg" style="height:36px">
</div>
</q-toolbar-title>
<q-btn flat class="no-pointer-events" color="secondary">{{username}}</q-btn>
<q-btn
flat
size="18px"
icon="logout"
icon="o_logout"
@click="unsetUser()"
color="secondary"
/>
@ -49,7 +49,7 @@
<q-btn round
flat
size="md"
icon="add"
icon="o_add"
:title="`Ajouter un(e) ${category.name}`"
@click.stop.prevent="addItem(category)"
>
@ -70,7 +70,7 @@
>
<q-item-section>Tâches</q-item-section>
<q-item-section side>
<q-icon name="task"></q-icon>
<q-icon name="o_task"></q-icon>
</q-item-section>
</q-item>
<q-item :to="'/print-settings'"
@ -78,7 +78,7 @@
>
<q-item-section>Impression</q-item-section>
<q-item-section side>
<q-icon name="print"></q-icon>
<q-icon name="o_print"></q-icon>
</q-item-section>
</q-item>
</q-list>
@ -164,7 +164,7 @@ export default {
this.$q.notify({
message: `Enchanté ${username} !`,
color: 'green',
avatar: '/gyoza_logo_nobg.png',
avatar: '/gyoza.svg',
timeout: 2000
})
})

View File

@ -1,5 +1,5 @@
<template>
<q-layout> <!-- Be sure to play with the Layout demo on docs -->
<q-layout class="bg-dark-page">
<q-page-container>
<router-view />
</q-page-container>

View File

@ -1,27 +1,412 @@
<template>
<q-page class="flex flex-center flex-">
<div v-if="username" class="q-py-lg">
<GlobalSearch />
<Feed />
<q-page class="dashboard-page q-pa-lg">
<div class="welcome-row row items-center q-mb-lg">
<div class="col">
<div class="text-h4 text-weight-bold">Tableau de bord</div>
<div class="text-grey-5 text-body2 q-mt-xs">
{{ greeting }} <span class="text-capitalize text-primary">{{ username }}</span>
</div>
</div>
</div>
<div class="stats-grid q-mb-lg">
<q-card flat class="stat-card" v-ripple @click="$router.push('/tasks')">
<q-card-section class="row items-center">
<div class="stat-icon bg-orange-1 text-orange" style="background:rgba(249,115,22,0.12)">
<q-icon name="o_task" size="28px" color="orange-5" />
</div>
<div class="q-ml-md">
<div class="text-h5 text-weight-bold">{{ openTasks }}</div>
<div class="text-caption text-grey-5">Tâches ouvertes</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="stat-card">
<q-card-section class="row items-center">
<div class="stat-icon" style="background:rgba(6,182,212,0.12)">
<q-icon name="o_inventory_2" size="28px" color="primary" />
</div>
<div class="q-ml-md">
<div class="text-h5 text-weight-bold">{{ totalItems }}</div>
<div class="text-caption text-grey-5">Articles au total</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="stat-card">
<q-card-section class="row items-center">
<div class="stat-icon" style="background:rgba(16,185,129,0.12)">
<q-icon name="o_check_circle" size="28px" color="green-5" />
</div>
<div class="q-ml-md">
<div class="text-h5 text-weight-bold">{{ inStock }}</div>
<div class="text-caption text-grey-5">En stock</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="stat-card">
<q-card-section class="row items-center">
<div class="stat-icon" style="background:rgba(239,68,68,0.12)">
<q-icon name="o_warning" size="28px" color="red-5" />
</div>
<div class="q-ml-md" title="État abimé ou inutilisable">
<div class="text-h5 text-weight-bold">{{ needsAttention }}</div>
<div class="text-caption text-grey-5">À vérifier</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="search-section q-mb-lg">
<q-input v-model="srcTxt"
filled
clearable
@update:model-value="updateSearchUrl"
placeholder="Rechercher une référence ou un nom..."
color="primary"
class="search-input full-width"
style="max-width:100%"
>
<template v-slot:prepend>
<q-icon name="o_search" />
</template>
<template v-slot:append>
<q-icon v-if="!srcTxt" name="o_keyboard" class="text-grey-6" size="xs">
<q-tooltip>Saisissez au moins 3 caractères</q-tooltip>
</q-icon>
</template>
</q-input>
<q-card flat v-if="searchResults.length > 0" class="search-results q-mt-sm">
<q-card-section class="q-pb-none">
<div class="text-subtitle2 text-grey-5">
{{ searchResults.length }} résultat{{ searchResults.length > 1 ? 's' : '' }}
</div>
</q-card-section>
<q-list dense>
<q-item clickable
v-for="result in searchResults"
:key="result.ref"
:to="`/item/${result.ref}`"
:class="{ 'deleted-item': result.deleted }">
<q-item-section avatar>
<q-icon :name="result.deleted ? 'o_delete' : 'o_description'"
:color="result.deleted ? 'red' : 'grey-6'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ result.name || 'Sans nom' }}</q-item-label>
<q-item-label caption>
<q-chip dense size="sm" class="q-mr-xs" color="primary" text-color="white">
{{ result.type }}
</q-chip>
#{{ result.ref }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-chip dense :color="stateColor(result.state)" text-color="white" size="sm">
{{ result.state || '?' }}
</q-chip>
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-7">
<q-card flat class="feed-card">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-medium text-primary">Activité récente</div>
<q-space />
<q-btn flat dense label="Voir tout" color="primary" size="sm" class="text-caption" />
</q-card-section>
<q-card-section class="q-pt-sm">
<div v-if="lastActivity.length === 0" class="text-center q-py-xl text-grey-5">
<q-icon name="o_history" size="3rem" class="q-mb-sm" />
<div>Aucune activité pour le moment</div>
</div>
<div v-else class="feed-list">
<div v-for="event in lastActivity" :key="event.id"
class="feed-item q-py-sm q-px-md cursor-pointer"
@click="$router.push(`/item/${event.ref}`)">
<div class="row items-start no-wrap">
<q-icon :name="event.type === 'issue' ? 'o_task' : 'o_chat'"
:color="event.type === 'issue' ? 'orange-5' : 'primary'"
size="20px"
class="q-mt-xs q-mr-md" />
<div class="col">
<div class="feed-text text-body2" v-html="event.text.split('\n').join('<br>')"></div>
<div class="text-caption text-grey-5 q-mt-xs">
<span class="text-capitalize text-weight-medium text-grey-4">{{ event.user }}</span>
sur <b>{{ getItemName(event.ref) }}</b>
<span class="text-grey-6">· {{ timeAgo(event.date || event.created) }}</span>
</div>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5">
<q-card flat class="section-card q-mb-md">
<q-card-section>
<div class="text-subtitle1 text-weight-medium text-primary q-mb-sm">Catégories</div>
<div class="category-list">
<div v-for="cat in categoryStats" :key="cat.code"
class="category-row row items-center q-py-sm cursor-pointer"
@click="$router.push(`/category/${cat.code}`)">
<div class="col">
<span class="text-body2">{{ cat.name }}</span>
</div>
<div class="col-auto row items-center q-gutter-x-sm">
<span class="text-caption text-grey-5">{{ cat.count }} article{{ cat.count > 1 ? 's' : '' }}</span>
</div>
</div>
</div>
</q-card-section>
</q-card>
<q-card flat class="section-card" v-if="recentIssues.length > 0">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-medium text-primary">Tâches ouvertes</div>
<q-space />
<q-btn flat dense label="Voir tout" color="orange-5" size="sm" class="text-caption" to="/tasks" />
</q-card-section>
<q-card-section class="q-pt-sm">
<div v-for="issue in recentIssues" :key="issue.id"
class="issue-row q-py-sm q-px-sm cursor-pointer rounded-borders"
@click="$router.push(`/item/${issue.ref}`)">
<div class="row items-center no-wrap">
<q-icon name="o_error" color="orange-5" size="18px" class="q-mr-sm" />
<div class="col">
<div class="text-body2 ellipsis">{{ issue.text }}</div>
<div class="text-caption text-grey-5">
{{ getItemName(issue.ref) }}
<span class="text-grey-6">· {{ timeAgo(issue.date || issue.created) }}</span>
</div>
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
</q-page>
</template>
<script>
import Feed from '../components/Feed.vue'
import GlobalSearch from '../components/GlobalSearch.vue'
import { date } from 'quasar'
export default {
name: 'DashboardPage',
components: { Feed, GlobalSearch },
data () {
return {
srcTxt: null
}
},
computed: {
username () {
return this.$store.state.core.username
},
greeting () {
const hour = new Date().getHours()
if (hour < 6) return 'Bonne nuit'
if (hour < 12) return 'Bonjour'
if (hour < 18) return 'Bon après-midi'
return 'Bonsoir'
},
stock () {
return this.$store.state.core.firebase.stock || []
},
history () {
return this.$store.state.core.firebase.history || []
},
categories () {
return this.$store.state.core.firebase.categories || []
},
activeItems () {
return this.stock.filter(i => !i.deleted)
},
totalItems () {
return this.activeItems.length
},
inStock () {
return this.activeItems.filter(i => i.available).length
},
needsAttention () {
return this.activeItems.filter(i => i.working === false || i.state === 'Abimé' || i.state === 'Inutilisable').length
},
openTasks () {
return this.history.filter(i => i.type === 'issue' && !i.solved_at).length
},
searchResults () {
if (!this.srcTxt || this.srcTxt.length < 3) return []
return this.stock.filter(item => {
const search = this.srcTxt.toLowerCase()
return (item.ref.toLowerCase() + (item.name || '').toLowerCase()).includes(search)
})
},
lastActivity () {
return [...this.history].sort((a, b) => {
const da = new Date(a.date || a.created)
const db = new Date(b.date || b.created)
return db - da
}).slice(0, 10)
},
recentIssues () {
return this.history
.filter(i => i.type === 'issue' && !i.solved_at)
.sort((a, b) => {
const da = new Date(a.date || a.created)
const db = new Date(b.date || b.created)
return db - da
})
.slice(0, 5)
},
categoryStats () {
return this.categories.map(cat => ({
...cat,
count: this.activeItems.filter(i => i.type === cat.name).length
})).filter(c => c.count > 0)
.sort((a, b) => b.count - a.count)
}
},
mounted () {
created () {
this.srcTxt = this.$route.query.search || null
},
methods: {
stateColor (state) {
const colors = {
Neuf: 'green-8',
Excellent: 'green',
Bon: 'lime-9',
Moyen: 'orange',
Abimé: 'red',
Inutilisable: 'grey-7'
}
return colors[state] || 'grey'
},
getItemName (ref) {
const item = this.stock.find(i => i.ref === ref)
return item ? item.name || 'Sans nom' : ref
},
timeAgo (timestamp) {
const d = new Date(timestamp)
const now = Date.now()
const hoursDiff = date.getDateDiff(now, d, 'hours')
if (hoursDiff < 1) return 'à l\'instant'
if (hoursDiff < 24) return `il y a ${hoursDiff}h`
const daysDiff = date.getDateDiff(now, d, 'days')
return `il y a ${daysDiff}j`
},
updateSearchUrl () {
this.$router.replace({ path: this.$router.path, query: { search: this.srcTxt } })
}
}
}
</script>
<style>
<style scoped>
.dashboard-page {
max-width: 1300px;
margin: 0 auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
border-radius: 16px;
cursor: default;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-card:first-child {
cursor: pointer;
}
.stat-card:first-child:hover {
box-shadow: 0 4px 16px rgba(249, 115, 22, 0.12);
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.search-input {
max-width: 680px;
}
.search-results {
max-width: 680px;
border-radius: 16px;
}
.deleted-item {
opacity: 0.5;
}
.feed-card {
border-radius: 16px;
height: 100%;
}
.feed-list {
max-height: 560px;
overflow-y: auto;
}
.feed-item {
border-radius: 10px;
transition: background 0.15s;
}
.feed-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.feed-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.section-card {
border-radius: 16px;
}
.category-row {
border-radius: 10px;
padding: 4px 12px;
transition: background 0.15s;
}
.category-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.issue-row {
transition: background 0.15s;
}
.issue-row:hover {
background: rgba(255, 255, 255, 0.04);
}
</style>

View File

@ -1,18 +1,43 @@
<template>
<q-page class="flex flex-center">
<div class="text-center">
<img alt="Quasar logo" src="~assets/gyoza.jpg" style="max-width:80vw">
<q-page class="flex flex-center bg-dark-page">
<div class="login-card text-center">
<img alt="Gyoza logo" src="~assets/gyoza.svg" style="width:180px" class="login-logo">
<br/>
<q-input label="Username" v-model="username" class="q-mt-md" autofocus @keydown.enter="login"></q-input>
<q-input dark outlined color="primary" label="Username" v-model="username" class="q-mt-md" autofocus @keydown.enter="login">
<template v-slot:prepend>
<q-icon name="o_person_outline" />
</template>
</q-input>
<br/>
<q-input label="Password" type="password" v-model="password" @keydown.enter="login"></q-input>
<q-input dark outlined color="primary" label="Password" type="password" v-model="password" @keydown.enter="login">
<template v-slot:prepend>
<q-icon name="o_lock_outline" />
</template>
</q-input>
<br/>
<q-btn label="Login" color="primary" @click="login" ></q-btn>
<q-btn label="Login" color="primary" rounded class="full-width" @click="login" />
</div>
</q-page>
</template>
<style>
<style scoped>
.login-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 48px 32px;
max-width: 400px;
width: 90vw;
}
.login-logo {
border-radius: 16px;
margin-bottom: 8px;
}
.q-input :deep(.q-field__control) {
border-radius: 12px;
}
</style>
<script>

View File

@ -129,10 +129,10 @@ export default {
height: 100%;
width:100%;
page-break-after: always;
border: 1px solid gray;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.grid-item {
border: 1px solid gray;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
display: flex;
flex-direction: column;

View File

@ -1,244 +1,310 @@
<template>
<q-page class="q-pa-md">
<q-btn @click="generateQRCode" class="float-right" icon="qr_code"></q-btn>
<div class="q-ma-none text-h3 text-weight-bolder row" :class="{'text-faded': !item.name}">
<span class="q-mr-md">{{ item.name || '---- -- ----' }}</span>
<q-chip square size="lg" class="text-white q-mr-md" color="secondary">{{item.ref}}</q-chip>
<q-chip square outline size="lg" class="q-mr-md" color="secondary">{{item.short_id}}</q-chip>
<q-chip square size="lg" v-if="item.deleted" color="red">Supprimé</q-chip>
<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>
<h5 class="q-ma-none q-pb-md text-grey">{{item.type}}</h5>
<q-dialog v-model="showQr">
<q-card style="width: 500px">
<q-card-section>
<div class="text-h6">{{ item.name }}</div>
<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-section class="q-pt-none">
<q-img :src="qrCodeDataUrl" v-if="qrCodeDataUrl" alt="QR Code" class="" style="max-height:500px;max-width:500px" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" v-close-popup />
<q-btn flat label="Print" @click="openQr"/>
<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>
<div class="row q-col-gutter-xl">
<div class="col-12 col-lg-6">
<div class="row">
<div class="col">
<h4 class="q-ma-none q-pb-md text-light">Informations</h4>
</div>
</div>
<div>
<q-input v-model="item.name"
:disable="item.deleted"
type="text"
label="Nom"
outlined color="secondary"
class="q-mb-md"
@update:model-value="changed = true">
</q-input>
<q-input v-model="item.owner"
:disable="item.deleted"
outlined color="secondary" stack-label label="Propriétaire" class="q-mb-md"
@input="changed = true"/>
<q-field borderless stack-label label="Etat cosmétique" class="q-mb-md">
<q-btn-toggle
class="q-mt-sm"
no-caps
:disable="item.deleted"
v-model="item.state"
@update:model-value="changed = true"
toggle-color="secondary"
text-color="black"
:options="[
{label: 'Neuf', value: 'Neuf'},
{label: 'Excellent', value: 'Excellent'},
{label: 'Bon', value: 'Bon'},
{label: 'Moyen', value: 'Moyen'},
{label: 'Abimé', value: 'Abimé'},
{label: 'Inutilisable', value: 'Inutilisable'}
]"
/>
</q-field>
<q-field borderless stack-label label="Fonctionnel" class="q-mb-md">
<q-btn-toggle
class="q-mt-sm"
no-caps
:disable="item.deleted"
v-model="item.working"
:toggle-color="item.working ? 'green' : 'red'"
text-color="black"
:options="[
{label: '✔', value: true},
{label: '?', value: null},
{label: '✘', value: false}
]"
@input="changed = true"
/>
</q-field>
<q-field borderless stack-label label="En stock" class="q-mb-md">
<q-btn-toggle
class="q-mt-sm"
no-caps
:disable="item.deleted"
v-model="item.available"
:toggle-color="item.available ? 'green' : 'red'"
text-color="black"
:options="[
{label: '✔', value: true},
{label: '✘', value: false}
]"
@input="changed = true"
/>
</q-field>
<q-field borderless stack-label label="Supprimé" class="q-mb-md">
<q-btn-toggle
no-caps
v-model="item.deleted"
:toggle-color="item.deleted ? 'green' : 'red'"
text-color="black"
:options="[
{label: 'Oui', value: true},
{label: 'Non', value: false}
]"
@input="changed = true"
class="q-mt-sm"
/>
</q-field>
<q-input
:disable="item.deleted"
v-model="item.comment"
@update:model-value="changed = true"
type="textarea"
:max-height="100"
rows="5"
outlined color="secondary" stack-label label="Commentaire" class="q-mb-md"
inverted
/>
<q-btn :disabled="!changed || originalItem.deleted"
@click="updateItem()"
color="primary"
:flat="!changed"
>Enregistrer</q-btn>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="row">
<div class="col">
<h4 class="q-ma-none q-pb-md text-light">Historique</h4>
</div>
<div class="col text-right">
<q-btn outline icon="add" label="ajouter une entrée" color="primary" @click="openHistoryForm()"></q-btn>
</div>
</div>
<q-list bordered separator class="rounded-borders">
<q-item clickable
v-for="event in history"
:key="event.id"
@click="showHistory = true;selectedHistory = event"
>
<q-item-section top thumbnail class="q-ml-none" style="margin:-8px 0 -8px -16px">
<img
:src="getImageUrl(event, event.image_file, true)"
alt="photo"
style="object-fit: cover"
v-if="event.image_file"
class="full-height"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="3" v-html="event.text.split('\n').join('</br>')"></q-item-label>
<q-item-label caption>
<q-icon name="task" class="q-mr-sm" size="xs"
v-if="event.type === 'issue'"
:color="event.solved_at ? 'green' : 'grey'"
/>
<span class="text-capitalize">{{ event.user }}</span>
<template v-if="event.solved_at">
<q-icon name="arrow_right" size="sm"/>
fait par <span class="text-capitalize">{{ event.solved_by }}</span> le {{getDate(event.solved_at)}}
</template>
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ getDate(event.date || event.created) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<q-dialog v-model="showHistoryForm">
<q-card>
<q-card style="min-width:420px">
<q-card-section>
<div class="text-h6">Ajouter une entrée</div>
</q-card-section>
<q-card-section>
<q-input
v-model="pendingHistory"
autofocus
type="textarea"
label="Description"
:max-height="100"
rows="3"
invertedd
color="secondary"
/>
<br/>
<q-btn-group outline spread>
<q-btn :outline="pendingHistoryType !== 'comment'" color="secondary" label="Info" icon="info" @click="pendingHistoryType = 'comment'"/>
<q-btn :outline="pendingHistoryType !== 'issue'" color="secondary" label="Tâche" icon="task" @click="pendingHistoryType = 'issue'"/>
<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>
<br/>
<q-uploader
:accept="'.jpg,.png'"
:factory="uploadFile"
label="Uploader une photo"
@added="hasFile = true"
ref="uploader"
no-thumbnails
hide-upload-btn
flat
bordered
color="secondary"
/>
<br/>
<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-separator>
<q-separator />
<q-card-actions align="right">
<q-btn flat color="grey" @click.stop="showHistoryForm = false">Annuler</q-btn>
<q-btn flat color="primary" @click.stop="addHistory()">Ok</q-btn>
<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>
<img :src="getImageUrl(selectedHistory, selectedHistory.image_file)"
alt="photo"
v-if="selectedHistory.image_file"
/>
<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 v-html="selectedHistory.text.split('\n').join('</br>')"></div>
<div class="text-caption text-grey">
<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>
<q-separator vertical />
<span>{{ getDate(selectedHistory.date || selectedHistory.created) }}</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="secondary" label="Marquer comme terminé" @click="setTaskDone(selectedHistory)"></q-btn>
<q-btn outline color="primary" icon="o_check" label="Marquer comme terminé"
@click="setTaskDone(selectedHistory)" />
</q-card-section>
<q-separator/>
<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 color="red" @click.stop="removeHistory(selectedHistory)">Supprimer</q-btn>
<q-btn flat color="grey" @click.stop="showHistory = false;selectedHistory = null">Fermer</q-btn>
<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>
@ -270,8 +336,15 @@ export default {
}
},
computed: {
category () {
return this.item.type || ''
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 || []
@ -299,21 +372,29 @@ export default {
let itemRef = this.stock.find(item => {
return item.short_id === parseInt(this.shortId)
})?.ref
console.log('Redirecting to item with ref:', itemRef)
if (itemRef) this.$router.push(`/item/${itemRef}`)
}
this.loadItem()
this.$watch(
() => this.itemRef,
(newId, oldId) => {
this.loadItem()
}
() => { this.loadItem() }
)
},
mounted () {
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')
},
@ -322,7 +403,7 @@ export default {
},
async addHistory () {
this.$q.loading.show()
let date = new Date()
let historyDate = new Date()
let file = null
if (this.hasFile) {
file = await this.uploadFile(this.$refs.uploader.files[0])
@ -335,14 +416,13 @@ export default {
type: this.pendingHistoryType,
user: this.$store.state.core.username,
image_file: file,
date: date.toISOString()
date: historyDate.toISOString()
}
}).then(response => {
}).then(_ => {
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.showHistoryForm = false
this.pendingHistory = null
this.pendingHistoryDate = null
this.hasFile = false
this.$refs.uploader.reset()
this.$q.loading.hide()
@ -352,7 +432,7 @@ export default {
removeHistory (data) {
this.$q.dialog({
title: 'Supprimer ?',
message: "Il est possible que tu sois Meth, nous sommes donc légalement obligés de te demander : es-tu absolument sur d'être certain de vouloir effectuer cette action irréversible ?",
message: 'Es-tu certain de vouloir supprimer cette entrée ?',
ok: 'Supprimer',
cancel: 'Annuler'
}).onOk(() => {
@ -362,6 +442,8 @@ export default {
}).then(_ => {
this.$store.dispatch('core/loadAppData').then(_ => {
this.loadItem()
this.showHistory = false
this.selectedHistory = null
this.$q.notify('Supprimé !')
})
})
@ -373,12 +455,11 @@ export default {
collection: 'stock',
itemId: this.itemId,
data: this.item
}).then(response => {
// this.$speech(`sugoï, domo arigato senpai !`)
}).then(_ => {
this.$q.notify({
message: 'Enregistré !',
color: 'secondary',
avatar: '/gyoza_logo_nobg.png',
color: 'primary',
avatar: '/gyoza.svg',
timeout: 2000
})
this.changed = false
@ -406,7 +487,6 @@ export default {
})
},
async uploadFile (file) {
// Resize and compress the image before uploading
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 2000,
@ -441,7 +521,6 @@ export default {
}
},
openQr () {
// Take the data url qr code and display it in a new tab
const win = window.open()
win.document.write(`<img src="${this.qrCodeDataUrl}" alt="QR Code" style="max-height:500px;max-width:500px" />`)
win.document.close()
@ -451,7 +530,146 @@ export default {
</script>
<style scoped>
.q-field__label{
padding-bottom:16px !important;
.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>

View File

@ -1,69 +1,125 @@
<template>
<q-page padding>
<h4 class="q-mt-none">{{category.name}}</h4>
<q-table
flat
bordered
:title="category.name"
<q-page class="items-page q-pa-lg">
<div class="row items-center q-mb-md">
<div class="col">
<div class="text-h4 text-weight-bold">{{ category.name }}</div>
<div class="text-caption text-grey-5 q-mt-xs">
{{ items.length }} article{{ items.length !== 1 ? 's' : '' }}
<span v-if="!showDeleted && (deletedCount > 0)" class="text-grey-6">
· {{ deletedCount }} supprimé{{ deletedCount !== 1 ? 's' : '' }}
</span>
</div>
</div>
<q-btn outline rounded color="primary" icon="o_add" label="Ajouter" @click.stop.prevent="addItem" />
</div>
<q-card flat class="table-card">
<q-card-section class="row items-center q-pb-none">
<q-input v-model="search"
filled
dense
clearable
@update:model-value="updateSearchUrl"
placeholder="Rechercher..."
color="primary"
class="search-field">
<template v-slot:prepend>
<q-icon name="o_search" />
</template>
</q-input>
<q-space />
<q-toggle v-model="showDeleted"
dense
color="grey-5"
class="q-ml-md">
<template v-slot:label>
<span class="text-caption text-grey-5">Supprimés</span>
</template>
</q-toggle>
</q-card-section>
<q-table
flat
:rows="items"
:columns="dynamicColumns"
v-model:pagination="pagination"
row-key="ref"
@update:pagination="updatePaginationUrl"
>
hide-pagination
class="stock-table"
:rows-per-page-options="[10, 15, 25, 50]"
>
<template v-slot:body="props">
<q-tr :props="props"
@click="goToDetail(props)"
class="table-row"
:class="{ 'row-deleted': props.row.deleted }">
<q-td key="ref" class="text-weight-medium">
<span class="text-primary">{{ props.row.ref }}</span>
</q-td>
<q-td key="name">
<span :class="{ 'text-faded': !props.row.name }">
{{ props.row.name || '—' }}
</span>
</q-td>
<q-td key="state" class="text-center">
<q-chip dense square
:color="stateColor(props.row.state)"
text-color="white"
size="sm"
:label="props.row.state || '?'" />
</q-td>
<q-td key="working" class="text-center">
<q-icon v-if="props.row.working === true" name="o_check_circle" color="green" size="18px" />
<q-icon v-else-if="props.row.working === false" name="o_cancel" color="red" size="18px" />
<q-icon v-else name="o_help" color="grey-6" size="18px" />
</q-td>
<q-td key="available" class="text-center">
<q-icon v-if="props.row.available" name="o_check_circle" color="green" size="18px" />
<q-icon v-else name="o_cancel" color="red" size="18px" />
</q-td>
<q-td key="owner">
{{ props.row.owner }}
</q-td>
<q-td v-if="showDeleted" key="deleted" class="text-center">
<q-badge v-if="props.row.deleted" color="red" label="Supprimé" />
</q-td>
</q-tr>
</template>
<template v-slot:top>
<div class="full-width">
<q-toggle v-model="showDeleted"
label="Voir items supprimés"
class="q-mr-md float-right"
/>
<q-input
borderless
clearable
dense
icon="search"
color="secondary"
v-model="search"
class="col-6"
@update:model-value="updateSearchUrl"
placeholder="Search"
>
<template v-slot:prepend>
<q-icon name="search" color="secondary" />
</template>
</q-input>
</div>
</template>
<template v-slot:bottom>
<div v-if="pagesNumber > 1" class="row items-center justify-center q-py-sm full-width">
<q-pagination v-model="pagination.page"
:max="pagesNumber"
:max-pages="7"
boundary-numbers
direction-links
color="primary"
active-design="unelevated"
active-color="primary"
active-text-color="white" />
</div>
</template>
</q-table>
</q-card>
<template v-slot:body="props">
<q-tr
:props="props"
@click="goToDetail(props)"
:class="{'cursor-pointer': true, 'text-red': props.row.deleted}">
<q-td v-for="col in props.cols" :key="col.ref">
<div v-if="col.name === 'available'" class="text-center text-h6">
<span v-if="col.value" class="text-green"></span>
<span v-else class="text-red"></span>
</div>
<div v-else-if="col.name === 'working'" class="text-center text-h6">
<span v-if="col.value" class="text-green"></span>
<span v-else class="text-red"></span>
</div>
<div v-else-if="col.name === 'deleted'" class=" text-center text-h6">
<span v-if="col.value" class="text-red"></span>
</div>
<div v-else-if="col.name === 'state'" class="text-center text-h6">
<q-chip small :color="stateColor(col.value)" :title="col.value"></q-chip>
</div>
<span v-else>
{{ col.value }}
</span>
</q-td>
</q-tr>
</template>
</q-table>
<q-dialog v-model="showAddDialog">
<q-card style="min-width:420px">
<q-card-section>
<div class="text-h6">Ajouter un{{ category.name.endsWith('e') ? 'e' : '' }} {{ category.name }}</div>
<div class="text-caption text-grey-5">Référence : {{ newRef }}</div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input v-model="newName" filled autofocus label="Nom" color="primary"
@keydown.enter="confirmAddItem" />
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey" v-close-popup />
<q-btn flat label="Créer" color="primary" @click="confirmAddItem" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
@ -77,56 +133,83 @@ export default {
return {
search: null,
showDeleted: false,
showAddDialog: false,
newName: '',
pagination: {
sortBy: null,
descending: false,
page: 1,
rowsPerPage: 15
},
columns: [
{ label: 'Référence', align: 'left', field: 'ref', sortable: true, name: 'ref' },
{ label: 'Nom', align: 'left', field: 'name', sortable: true, name: 'name' },
{ label: 'Etat', align: 'center', field: 'state', sortable: true, name: 'state' },
{ label: 'Fonctionnel', align: 'center', field: 'working', sortable: true, name: 'working' },
{ label: 'En stock', align: 'center', field: 'available', sortable: true, name: 'available' },
{ label: 'Propriétaire', align: 'left', field: 'owner', sortable: true, name: 'owner' },
{ label: 'Supprimé', align: 'left', field: 'deleted', sortable: false, name: 'deleted', format: val => val ? '✔' : '' }
]
}
}
},
created () {
if (!this.search) this.search = this.$route.query.search || null
let paginationQuery = { ...this.$route.query }
delete paginationQuery.search
this.pagination = { ...this.pagination, ...paginationQuery }
console.log('__Pagination:', this.pagination)
const q = { ...this.$route.query }
delete q.search
this.pagination = { ...this.pagination, ...q }
},
computed: {
columns () {
const base = [
{ label: 'Référence', field: 'ref', sortable: true, name: 'ref', align: 'left' },
{ label: 'Nom', field: 'name', sortable: true, name: 'name', align: 'left' },
{ label: 'État', field: 'state', sortable: true, name: 'state', align: 'center' },
{ label: 'Fonctionnel', field: 'working', sortable: true, name: 'working', align: 'center' },
{ label: 'En stock', field: 'available', sortable: true, name: 'available', align: 'center' },
{ label: 'Propriétaire', field: 'owner', sortable: true, name: 'owner', align: 'left' }
]
if (this.showDeleted) {
base.push({ label: 'Supprimé', field: 'deleted', name: 'deleted', align: 'center' })
}
return base
},
dynamicColumns () {
let columns = JSON.parse(JSON.stringify(this.columns))
if (!this.showDeleted) columns.pop()
return columns
return this.columns
},
category () {
return this.$store.state.core.firebase.categories.find(item => {
return parseInt(item.code) === parseInt(this.categoryCode)
}) || {}
},
allItems () {
return (this.$store.state.core.firebase.stock || [])
.filter(item => item.type === this.category.name)
},
filteredItems () {
return this.allItems.filter(item => {
if (this.showDeleted ? false : item.deleted) return false
if (this.search && this.search.length > 0) {
const s = this.search.toLowerCase()
return (item.ref.toLowerCase() + (item.name || '').toLowerCase()).includes(s)
}
return true
})
},
items () {
let items = this.$store.state.core.firebase.stock.filter(item => {
return item.type === this.category.name &&
(this.search ? (item.ref + (item.name + '').toLowerCase()).search(this.search.toLowerCase()) !== -1 : true) &&
(this.showDeleted ? true : !item.deleted)
}).slice().reverse() || []
let list = [...this.filteredItems]
if (this.pagination.sortBy) {
console.log('__Sorting:', this.pagination)
return [...items].sort((a, b) => {
const x = this.pagination.descending ? b : a
const y = this.pagination.descending ? a : b
return x[this.pagination.sortBy] > y[this.pagination.sortBy]
const desc = this.pagination.descending
list.sort((a, b) => {
const av = a[this.pagination.sortBy]
const bv = b[this.pagination.sortBy]
if (av == null) return desc ? -1 : 1
if (bv == null) return desc ? 1 : -1
return desc ? (bv > av ? 1 : -1) : (av > bv ? 1 : -1)
})
}
return items
return list
},
pagesNumber () {
return Math.ceil(this.filteredItems.length / this.pagination.rowsPerPage)
},
deletedCount () {
return this.allItems.filter(i => i.deleted).length
},
newRef () {
const stock = this.allItems
const lastRef = stock.length > 0 ? parseInt((stock[0].ref || '').split('-')[1] || 0) : 0
return `${this.category.code}-${String(lastRef + 1).padStart(4, '0')}`
}
},
methods: {
@ -134,31 +217,108 @@ export default {
this.$router.push(`/item/${data.key}`)
},
stateColor (state) {
let colors = {
Neuf: 'green-14',
Excellent: 'green-12',
Bon: 'lime-13',
const colors = {
Neuf: 'green-8',
Excellent: 'green',
Bon: 'lime-9',
Moyen: 'orange',
Abimé: 'red',
Inutilisable: 'black'
Inutilisable: 'grey-7'
}
return colors[state]
return colors[state] || 'grey'
},
addItem () {
this.showAddDialog = true
this.newName = ''
},
async confirmAddItem () {
const shortId = await this.$store.dispatch('core/getNextShortId')
const ref = this.newRef
const item = {
available: true,
comment: '',
name: this.newName,
owner: 'HFS',
ref,
state: null,
type: this.category.name,
short_id: shortId
}
await this.$store.dispatch('core/addToCollection', { collection: 'stock', data: item })
await this.$store.dispatch('core/addToCollection', {
collection: 'history',
data: {
ref,
text: 'Entrée en stock',
user: this.$store.state.core.username,
date: new Date().toISOString()
}
})
await this.$store.dispatch('core/loadAppData')
this.showAddDialog = false
this.$router.push(`/item/${ref}`)
},
updateSearchUrl: debounce(function () {
this.$router.replace({ path: this.$router.path, query: { ...this.$route.query, search: this.search } })
this.$router.replace({ path: this.$router.path, query: { search: this.search } })
}, 300),
updatePaginationUrl: debounce(function () {
this.$router.replace({
path: this.$router.path,
query: {
...this.$route.query,
...this.pagination
}
query: { ...this.$route.query, ...this.pagination }
})
}, 300)
}
}
</script>
<style>
<style scoped>
.items-page {
max-width: 1300px;
margin: 0 auto;
}
.table-card {
border-radius: 16px;
}
.search-field {
width: 320px;
}
.stock-table :deep(.q-table__top),
.stock-table :deep(.q-table__bottom) {
border: none;
padding: 0;
}
.stock-table :deep(thead th) {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.45);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 10px 16px;
}
.table-row {
transition: background 0.12s;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.table-row:hover {
background: rgba(255, 255, 255, 0.04) !important;
}
.table-row td {
padding: 10px 16px;
}
.row-deleted {
opacity: 0.4;
}
.row-deleted:hover {
opacity: 0.7;
}
</style>

View File

@ -1,125 +1,159 @@
<template>
<q-page padding>
<h4 class="q-mt-none">Tâches</h4>
<q-page class="tasks-page q-pa-lg">
<div class="row items-center q-mb-lg">
<div class="col">
<div class="text-h4 text-weight-bold">Tâches</div>
<div class="text-caption text-grey-5 q-mt-xs">
{{ openCount }} ouverte{{ openCount !== 1 ? 's' : '' }}
· {{ closedCount }} résolue{{ closedCount !== 1 ? 's' : '' }}
</div>
</div>
</div>
<q-list bordered separator class="rounded-borders">
<q-item clickable
v-for="issue in issues"
:key="issue.id"
@click="showHistory = true;selectedHistory = issue"
>
<q-item-section avatar>
<q-icon name="task"
:color="issue.solved_at ? 'green' : 'grey'"
/>
</q-item-section>
<q-item-section top thumbnail class="q-ml-none" style="margin:-8px 0 -8px -16px">
<img
:src="getImageUrl(issue, issue.image_file, true)"
alt="photo"
style="object-fit: cover;height:100%"
v-if="issue.image_file"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="3" v-html="issue.text.split('\n').join('</br>')"></q-item-label>
<q-item-label caption>
<span class="text-secondary">{{ issue.item.type }} / </span>
<span class="text-secondary q-mr-sm">{{ issue.item.name }}</span>
<span v-if="issue.item.ref">#{{ issue.item.ref }}</span>
</q-item-label>
<q-item-label caption>
<span class="text-capitalize">{{ issue.user }}</span>
<template v-if="issue.solved_at">
<q-icon name="arrow_right" size="sm"/>
fait par <span class="text-capitalize">{{ issue.solved_by }}</span> le {{ getDate(issue.solved_at) }}
</template>
</q-item-label>
</q-item-section>
<q-item-section side top>
<q-item-label caption>{{ getDate(issue.created) }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card flat class="tasks-card">
<q-card-section class="row items-center q-pb-none">
<q-tabs v-model="filterTab" dense class="text-grey-5" active-color="primary" indicator-color="primary">
<q-tab name="open" :label="`Ouvertes (${openCount})`" />
<q-tab name="closed" :label="`Résolues (${closedCount})`" />
<q-tab name="all" label="Toutes" />
</q-tabs>
</q-card-section>
<q-card-section class="q-pt-none">
<div v-if="filteredIssues.length === 0" class="text-center q-py-xl text-grey-5">
<q-icon name="o_check_circle" size="3rem" class="q-mb-sm" />
<div>Aucune tâche {{ filterTab === 'closed' ? 'résolue' : '' }} pour le moment</div>
</div>
<q-list separator v-else>
<q-item v-for="issue in filteredIssues"
:key="issue.id"
clickable
class="task-item"
@click="showHistory = true; selectedHistory = issue">
<q-item-section avatar>
<q-icon :name="issue.solved_at ? 'o_check_circle' : 'o_error'"
:color="issue.solved_at ? 'green' : 'orange-5'"
size="22px" />
</q-item-section>
<q-item-section top thumbnail class="q-ml-sm" v-if="issue.image_file">
<img :src="getImageUrl(issue, issue.image_file, true)"
alt=""
class="task-thumb" />
</q-item-section>
<q-item-section>
<q-item-label lines="2" class="text-body2" v-html="issue.text.split('\n').join('<br>')" />
<q-item-label caption class="q-mt-xs">
<span class="text-primary">{{ issue.item.type }}</span>
<span class="text-grey-5"> / {{ issue.item.name || 'Sans nom' }}</span>
<span v-if="issue.item.ref" class="text-grey-6"> #{{ issue.item.ref }}</span>
</q-item-label>
<q-item-label caption>
<span class="text-capitalize text-grey-4">{{ issue.user }}</span>
<template v-if="issue.solved_at">
<q-icon name="o_arrow_right" size="xs" class="q-mx-xs" />
<span class="text-green">résolue par <span class="text-capitalize">{{ issue.solved_by }}</span></span>
<span class="text-grey-6"> · {{ getDate(issue.solved_at) }}</span>
</template>
<template v-else>
<span class="text-grey-6"> · {{ getDate(issue.date || issue.created) }}</span>
</template>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
<q-dialog v-model="showHistory">
<q-card>
<q-img :src="getImageUrl(selectedHistory, selectedHistory.image_file)"
alt="photo"
fit="cover"
v-if="selectedHistory.image_file"
/>
<q-card style="min-width:420px; max-width:560px">
<img v-if="selectedHistory.image_file"
:src="getImageUrl(selectedHistory, selectedHistory.image_file)"
alt=""
class="detail-image" />
<q-card-section>
<div v-html="selectedHistory.text.split('\n').join('</br>')"></div>
<div class="text-caption text-grey">
<q-btn no-caps
icon="exit_to_app"
color="secondary"
class="q-mt-sm"
:to="`/item/${selectedHistory.ref}`">
<span class="q-ml-md">{{ selectedHistory.item.type }} / </span>
<span class="q-mx-sm">{{ selectedHistory.item.name }}</span>
<span v-if="selectedHistory.item.ref">#{{ selectedHistory.item.ref }}</span>
</q-btn>
<q-separator class="q-my-md"/>
<span class="text-capitalize q-mr-sm">{{ selectedHistory.user }}</span>
<template v-if="selectedHistory.solved_at">
<q-icon name="arrow_right" size="sm"/>
fait par <span class="text-capitalize">{{ selectedHistory.solved_by }}</span> le {{ getDate(selectedHistory.solved_at) }}
</template>
<!-- <span>{{ getDate(selectedHistory.created) }}</span>-->
<div class="text-body1" v-html="selectedHistory.text.split('\n').join('<br>')" />
<div class="q-mt-sm row items-center q-gutter-xs">
<q-chip dense :color="selectedHistory.solved_at ? 'green' : 'orange'"
text-color="white"
:label="selectedHistory.solved_at ? 'Résolue' : 'Ouverte'"
size="sm" />
<span class="text-caption text-grey-5">
<span class="text-capitalize">{{ selectedHistory.user }}</span>
· {{ getDate(selectedHistory.date || selectedHistory.created) }}
</span>
</div>
</q-card-section>
<q-card-section v-if="!selectedHistory.solved_at">
<q-btn outline color="secondary" label="Marquer comme terminé" @click="setTaskDone(selectedHistory)"></q-btn>
<q-card-section v-if="selectedHistory.solved_at" class="q-pt-none">
<div class="text-caption text-green">
Résolue par <span class="text-capitalize">{{ selectedHistory.solved_by }}</span>
le {{ getDate(selectedHistory.solved_at) }}
</div>
</q-card-section>
<q-separator/>
<q-card-section>
<q-btn no-caps outline rounded
color="primary"
:to="`/item/${selectedHistory.ref}`"
icon="o_open_in_new"
label="Voir l'article" />
<q-btn v-if="!selectedHistory.solved_at"
no-caps outline rounded
color="green"
class="q-ml-sm"
icon="o_check"
label="Marquer résolue"
@click="setTaskDone(selectedHistory)" />
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn flat color="red" @click.stop="removeHistory(selectedHistory)">Supprimer</q-btn>
<q-btn flat color="grey" @click.stop="showHistory = false;selectedHistory = null">Fermer</q-btn>
<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>
<!-- <pre>{{ issues }}</pre>-->
</q-page>
</template>
<script>
import { date, debounce } from 'quasar'
import { date } from 'quasar'
export default {
name: 'TasksPage',
data () {
return {
showHistory: false,
selectedHistory: null
selectedHistory: null,
filterTab: 'open'
}
},
created () {
},
computed: {
rawIssues () {
return this.$store.state.core.firebase.history.filter(item => {
return item.type === 'issue'
}).reverse() || []
allIssues () {
return (this.$store.state.core.firebase.history || [])
.filter(item => item.type === 'issue')
.reverse()
.map(issue => {
const item = (this.$store.state.core.firebase.stock || [])
.find(i => i.ref === issue.ref) || {}
return { ...issue, item }
})
},
items () {
return this.$store.state.core.firebase.stock.filter(item => {
let refs = Array.from(new Set(this.rawIssues.map(issue => issue.ref)))
return refs.includes(item.ref)
}) || []
openIssues () {
return this.allIssues.filter(i => !i.solved_at)
},
issues () {
return this.rawIssues.map(issue => {
let item = this.items.find(item => item.ref === issue.ref) || {}
return {
...issue,
item
}
})
closedIssues () {
return this.allIssues.filter(i => i.solved_at)
},
filteredIssues () {
if (this.filterTab === 'open') return this.openIssues
if (this.filterTab === 'closed') return this.closedIssues
return this.allIssues
},
openCount () {
return this.openIssues.length
},
closedCount () {
return this.closedIssues.length
}
},
methods: {
getDate (timestamp) {
@ -149,5 +183,36 @@ export default {
}
</script>
<style>
<style scoped>
.tasks-page {
max-width: 1100px;
margin: 0 auto;
}
.tasks-card {
border-radius: 16px;
}
.task-item {
border-radius: 10px;
margin: 2px 0;
transition: background 0.12s;
}
.task-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.task-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 8px;
}
.detail-image {
width: 100%;
max-height: 280px;
object-fit: cover;
}
</style>