Nice UI
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 640 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 3.5 KiB |
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
153
src/css/app.scss
@ -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%;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||