Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5639f8031c | ||
|
|
d42e21bae7 | ||
|
|
504f215c5a | ||
|
|
54d814f9b2 | ||
|
|
7aa2ff1d3a | ||
|
|
cffc6acc2b | ||
|
|
2b08889218 | ||
|
|
0e51451ad2 | ||
|
|
c74032d1d0 | ||
|
|
53558c4b46 | ||
|
|
30c4232406 | ||
|
|
13328aeec3 | ||
|
|
a811644585 |
31 changed files with 1174 additions and 119 deletions
|
|
@ -2,6 +2,8 @@ FROM node:24.10-alpine3.22 AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++ libc6-compat
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --prefer-offline --no-audit
|
RUN npm ci --prefer-offline --no-audit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,6 @@
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
"vfonts": "^0.0.3",
|
"vfonts": "^0.0.3",
|
||||||
"vite": "^6.2.1",
|
"vite": "^6.2.1",
|
||||||
"vite-plugin-vue-devtools": "^7.7.2"
|
"vite-plugin-vue-devtools": "^8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
src/api/zeroPrices.js
Normal file
40
src/api/zeroPrices.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { apiClient } from '@/services/apiClient.js'
|
||||||
|
|
||||||
|
export const useZeroPrices = () => {
|
||||||
|
const getZeroPrices = async () => {
|
||||||
|
try {
|
||||||
|
return await apiClient.get('/merch/zeroprices')
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Get zero prices error: ', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteZeroPrices = async (list) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete('/merch/zeroprices', list)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.log('Delete target zero prices error: ', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteZeroPricesPeriod = async (start, end) => {
|
||||||
|
const params = new URLSearchParams({ start, end }).toString()
|
||||||
|
const url = `/merch/zeroprices/period${params ? `?${params}` : ''}`
|
||||||
|
const response = await apiClient.delete(url)
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
console.log('Delete period select zero prices error: ', response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getZeroPrices,
|
||||||
|
deleteZeroPrices,
|
||||||
|
deleteZeroPricesPeriod,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
import 'chartjs-adapter-date-fns'
|
import 'chartjs-adapter-date-fns'
|
||||||
|
import { originColors } from '@/services/colors.js'
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
Title,
|
Title,
|
||||||
|
|
@ -33,11 +34,6 @@ const props = defineProps({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const originColors = {
|
|
||||||
surugaya: '#2d3081',
|
|
||||||
mandarake: '#924646',
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = ref({
|
const chartData = ref({
|
||||||
datasets: [],
|
datasets: [],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
27
src/components/LabelDotTemplate.vue
Normal file
27
src/components/LabelDotTemplate.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#EF2D56FF'
|
||||||
|
},
|
||||||
|
bg_color: {
|
||||||
|
type: String,
|
||||||
|
default: '#FFFFFF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="color-dot" :style="{ borderColor: color, backgroundColor: bg_color }"></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid; /* чтобы border-color работал */
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
src/components/LabelTemplate.vue
Normal file
45
src/components/LabelTemplate.vue
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
default: 'label'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: '#EF2D56FF'
|
||||||
|
},
|
||||||
|
bg_color: {
|
||||||
|
type: String,
|
||||||
|
default: '#FFFFFF'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="custom_label"
|
||||||
|
:style="{color: color, backgroundColor: bg_color, borderColor: color}"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom_label {
|
||||||
|
font-family: 'Heebo', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025rem;
|
||||||
|
font-style: normal;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
-webkit-border-radius: 1.25rem;
|
||||||
|
-moz-border-radius: 1.25rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0.125rem;
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
-moz-box-shadow: none;
|
||||||
|
-box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
src/components/ManageLabels.vue
Normal file
7
src/components/ManageLabels.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<template>
|
||||||
|
<n-button type="primary">
|
||||||
|
<router-link :to="{ name: 'labels' }" class="router-link-button">
|
||||||
|
Manage labels
|
||||||
|
</router-link>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
@ -17,6 +17,7 @@ const mainMenu = computed(() => {
|
||||||
{ label: 'Collection', key: 'collection' },
|
{ label: 'Collection', key: 'collection' },
|
||||||
{ label: 'Charts', key: 'charts' },
|
{ label: 'Charts', key: 'charts' },
|
||||||
{ label: 'Parsers', key: 'parsers' },
|
{ label: 'Parsers', key: 'parsers' },
|
||||||
|
{ label: 'Zero prices', key: 'zeroprices' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ const authMenu = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{ label: 'Labels', key: 'labels' },
|
||||||
{ label: 'Personal', key: 'personal' },
|
{ label: 'Personal', key: 'personal' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
@ -137,7 +139,7 @@ const renderLabel = (option) => {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
min-width: 150px;
|
min-width: 250px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import ParsersView from '@/views/ParsersView.vue'
|
||||||
import PersonalView from '@/views/PersonalView.vue'
|
import PersonalView from '@/views/PersonalView.vue'
|
||||||
import AddMerchView from '@/views/AddMerchView.vue'
|
import AddMerchView from '@/views/AddMerchView.vue'
|
||||||
import DetailsView from '@/views/DetailsView.vue'
|
import DetailsView from '@/views/DetailsView.vue'
|
||||||
|
import LabelsView from '@/views/LabelsView.vue'
|
||||||
|
import ZeroPricesView from '@/views/ZeroPricesView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -52,6 +54,16 @@ const router = createRouter({
|
||||||
component: DetailsView,
|
component: DetailsView,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/labels',
|
||||||
|
name: 'labels',
|
||||||
|
component: LabelsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/zeroprices',
|
||||||
|
name: 'zeroprices',
|
||||||
|
component: ZeroPricesView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,25 @@ let isRefreshing = false
|
||||||
let refreshPromise = null
|
let refreshPromise = null
|
||||||
|
|
||||||
function createConfig(options = {}) {
|
function createConfig(options = {}) {
|
||||||
const authStore = useAuthStore()
|
const token = localStorage.getItem('accessToken');
|
||||||
const headers = {
|
const isFormData = options.body instanceof FormData;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authStore.accessToken) {
|
const headers = {
|
||||||
headers['Authorization'] = `Bearer ${authStore.accessToken}`
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
|
||||||
...options,
|
...options,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
async function refreshAccessToken() {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
|
@ -53,7 +55,8 @@ async function refreshAccessToken() {
|
||||||
})
|
})
|
||||||
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error
|
console.error('Refresh error:', error)
|
||||||
|
throw new Error('REFRESH_FAILED')
|
||||||
})
|
})
|
||||||
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -66,29 +69,32 @@ async function refreshAccessToken() {
|
||||||
|
|
||||||
async function request(url, options = {}, isRetry = false) {
|
async function request(url, options = {}, isRetry = false) {
|
||||||
const config = createConfig(options)
|
const config = createConfig(options)
|
||||||
|
|
||||||
const response = await fetch(`${BASE_URL}${url}`, config)
|
const response = await fetch(`${BASE_URL}${url}`, config)
|
||||||
|
|
||||||
if (response.status === 401 && !isRetry) {
|
if (response.status === 401 && !isRetry) {
|
||||||
try {
|
try {
|
||||||
const data = await refreshAccessToken()
|
const data = await refreshAccessToken()
|
||||||
|
|
||||||
const token = data.access_token
|
const token = data.access_token
|
||||||
if (!token) {
|
if (!token) throw new Error('Refresh response did not contain access_token')
|
||||||
throw new Error('Refresh response did not contain access_token')
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOptions = {
|
const newOptions = {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
'Authorization': `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return await request(url, newOptions, true)
|
return await request(url, newOptions, true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const authStore = useAuthStore()
|
if (e.message === 'REFRESH_FAILED') {
|
||||||
authStore.forceLogout()
|
const authStore = useAuthStore()
|
||||||
|
authStore.forceLogout()
|
||||||
|
console.warn('Force logout (refresh failed)', url)
|
||||||
|
} else {
|
||||||
|
console.error('Unexpected error during refresh', e)
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,9 +106,15 @@ async function request(url, options = {}, isRetry = false) {
|
||||||
} catch {
|
} catch {
|
||||||
errorData = {}
|
errorData = {}
|
||||||
}
|
}
|
||||||
throw new Error(errorData.message || `HTTP Error: ${response.status}`)
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
ok: false,
|
||||||
|
error: errorData.message || `HTTP Error: ${response.status}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let data = null
|
let data = null
|
||||||
const contentType = response.headers.get('content-type')
|
const contentType = response.headers.get('content-type')
|
||||||
if (contentType?.includes('application/json')) {
|
if (contentType?.includes('application/json')) {
|
||||||
|
|
@ -113,11 +125,7 @@ async function request(url, options = {}, isRetry = false) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { status: response.status, ok: response.ok, data }
|
||||||
status: response.status,
|
|
||||||
ok: response.ok,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
|
|
@ -132,14 +140,14 @@ export const apiClient = {
|
||||||
return request(url, {
|
return request(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: isFormData ? data : JSON.stringify(data),
|
body: isFormData ? data : JSON.stringify(data),
|
||||||
headers: isFormData
|
|
||||||
? {}
|
|
||||||
: { 'Content-Type': 'application/json' }
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
put: (url, data) => request(url, {
|
put: (url, data) => request(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
delete: (url) => request(url, { method: 'DELETE' }),
|
delete: (url, data) => request(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
src/services/colors.js
Normal file
4
src/services/colors.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export const originColors = {
|
||||||
|
surugaya: '#2d3081',
|
||||||
|
mandarake: '#924646',
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
import { apiClient } from '@/services/apiClient'
|
import { apiClient } from '@/services/apiClient'
|
||||||
import router from '@/router/index.js'
|
import router from '@/router/index.js'
|
||||||
|
|
||||||
|
|
@ -54,9 +54,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post('/user/auth/login', { email, password })
|
const response = await apiClient.post('/user/auth/login', { email, password })
|
||||||
setToken(response.data.access_token)
|
setToken(response.data.access_token)
|
||||||
|
await nextTick()
|
||||||
router.push({ name: 'collection'})
|
router.push({ name: 'collection'})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
107
src/stores/labelsStore.js
Normal file
107
src/stores/labelsStore.js
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiClient } from '@/services/apiClient.js'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
function safeParseJSON(str, fallback = []) {
|
||||||
|
if (str === null || str === undefined || str === 'null' || str === '') {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(str)
|
||||||
|
return Array.isArray(parsed) ? parsed : fallback
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse localStorage item "labels", using fallback:', e)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLabelsStore = defineStore('labels', () => {
|
||||||
|
//state
|
||||||
|
const labels = ref(safeParseJSON(localStorage.getItem('labels'), []))
|
||||||
|
|
||||||
|
//getters
|
||||||
|
|
||||||
|
//action
|
||||||
|
const createLabel = async (newLabel) => {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/merch/labels', newLabel)
|
||||||
|
await getLabels()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLabels = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/merch/labels?_=${Date.now()}`)
|
||||||
|
const labelList = Array.isArray(response.data) ? response.data : []
|
||||||
|
labels.value = labelList
|
||||||
|
localStorage.setItem('labels', JSON.stringify(labelList))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch labels:', error)
|
||||||
|
labels.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLabel = async (uuid, updatedData) => {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/merch/labels/${uuid}?_=${Date.now()}`, updatedData)
|
||||||
|
await getLabels()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteLabel = async (uuid) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/merch/labels/${uuid}`)
|
||||||
|
await getLabels()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachLabel = async (merchUuid, labelUuid) => {
|
||||||
|
try {
|
||||||
|
const payload = { merch_uuid: merchUuid, label_uuid: labelUuid }
|
||||||
|
await apiClient.post('/merch/labels/attach', payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to attach label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachLabel = async (merchUuid, labelUuid) => {
|
||||||
|
try {
|
||||||
|
const payload = { merch_uuid: merchUuid, label_uuid: labelUuid }
|
||||||
|
await apiClient.post('/merch/labels/detach', payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to detach label:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMerchLabels = async (merchUuid) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/merch/labels/${merchUuid}`)
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get merch labels:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
createLabel,
|
||||||
|
getLabels,
|
||||||
|
updateLabel,
|
||||||
|
deleteLabel,
|
||||||
|
attachLabel,
|
||||||
|
detachLabel,
|
||||||
|
getMerchLabels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-20 {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-10 {
|
.mb-10 {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -166,3 +170,35 @@ body,
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.router-link-button{
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-preview {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.padding-lr-30 {
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import CollectionMerchCard from '@/views/CollectionView/CollectionMerchCard.vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useMerchApi } from '@/api/merch.js'
|
import { useMerchApi } from '@/api/merch.js'
|
||||||
import ScrollToTopButton from '@/components/ScrollToTopButton.vue'
|
import ScrollToTopButton from '@/components/ScrollToTopButton.vue'
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore'
|
||||||
|
|
||||||
const merchList = ref(null)
|
const merchList = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
const { getMerchList } = useMerchApi()
|
const { getMerchList } = useMerchApi()
|
||||||
|
const { getLabels } = useLabelsStore()
|
||||||
|
|
||||||
const fetchMerch = async () => {
|
const fetchMerch = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -25,27 +27,39 @@ const fetchMerch = async () => {
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchMerch()
|
fetchMerch()
|
||||||
|
getLabels()
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
const selectedLabelUuids = ref([])
|
||||||
|
|
||||||
const filteredMerch = computed(() => {
|
const filteredMerch = computed(() => {
|
||||||
if (!searchQuery.value.trim()) {
|
let result = merchList.value || []
|
||||||
return merchList.value
|
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter((item) => item.name.toLowerCase().includes(q))
|
||||||
}
|
}
|
||||||
const q = searchQuery.value.toLowerCase()
|
|
||||||
return merchList.value.filter((item) => item.name.toLowerCase().includes(q))
|
if (selectedLabelUuids.value.length > 0) {
|
||||||
|
const selectedSet = new Set(selectedLabelUuids.value)
|
||||||
|
result = result.filter((item) => {
|
||||||
|
return item.labels && item.labels.some((labelUuid) => selectedSet.has(labelUuid))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sticky-search-container">
|
<div class="sticky-search-container">
|
||||||
<CollectionToolbar v-model="searchQuery" />
|
<CollectionToolbar v-model="searchQuery" v-model:labelUuids="selectedLabelUuids" />
|
||||||
</div>
|
</div>
|
||||||
<n-grid responsive="screen" cols="2 s:3 m:4 l:5 xl:6 2xl:7" class="grid-main">
|
<n-grid responsive="screen" cols="2 s:3 m:4 l:5 xl:6 2xl:7" class="grid-main">
|
||||||
<n-gi class="grid-item" v-for="item in filteredMerch" :key="item.merch_uuid">
|
<n-gi class="grid-item" v-for="item in filteredMerch" :key="item.merch_uuid">
|
||||||
<router-link :to="`/details/${item.merch_uuid}`" class="card-link">
|
<router-link :to="`/details/${item.merch_uuid}`" class="card-link">
|
||||||
<CollectionMerchCard :merch="item" />
|
<CollectionMerchCard :merch="item" :labels="item.labels" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import BoxIcon from '@/components/icons/BoxIcon.vue'
|
import BoxIcon from '@/components/icons/BoxIcon.vue'
|
||||||
import { useMerchImagesApi } from '@/api/merchImages.js'
|
import { useMerchImagesApi } from '@/api/merchImages.js'
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import LabelDotTemplate from '@/components/LabelDotTemplate.vue'
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore.js'
|
||||||
const { getImageUrl } = useMerchImagesApi()
|
const { getImageUrl } = useMerchImagesApi()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
merch: {
|
merch: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useLabelsStore()
|
||||||
|
|
||||||
|
const resolvedLabelDots = computed(() => {
|
||||||
|
const labelMap = new Map()
|
||||||
|
for (const label of store.labels) {
|
||||||
|
labelMap.set(label.label_uuid, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return props.labels
|
||||||
|
.map(uuid => labelMap.get(uuid))
|
||||||
|
.filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileList = ref([])
|
const fileList = ref([])
|
||||||
|
|
@ -44,6 +63,14 @@ onMounted(async () => {
|
||||||
<n-card class="responsive-card">
|
<n-card class="responsive-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3 class="card-title">{{ merch.name }}</h3>
|
<h3 class="card-title">{{ merch.name }}</h3>
|
||||||
|
<div v-if="resolvedLabelDots.length > 0" class="label-dots">
|
||||||
|
<LabelDotTemplate
|
||||||
|
v-for="label in resolvedLabelDots"
|
||||||
|
:key="label.label_uuid"
|
||||||
|
:color="label.color"
|
||||||
|
:bg_color="label.bg_color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cover>
|
<template #cover>
|
||||||
<div class="cover-wrapper">
|
<div class="cover-wrapper">
|
||||||
|
|
@ -101,4 +128,11 @@ onMounted(async () => {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,58 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import router from '@/router/index.js'
|
import router from '@/router/index.js'
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore.js'
|
||||||
|
|
||||||
|
const store = useLabelsStore()
|
||||||
const value = ref(null)
|
|
||||||
const options = [
|
|
||||||
{ label: 'Placeholder 1', value: 'Placeholder 1' },
|
|
||||||
{ label: 'Placeholder 2', value: 'Placeholder 2' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const addMerch = () => {
|
|
||||||
router.push({ name: 'addMerch' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
labelUuids: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:labelUuids'])
|
||||||
|
|
||||||
|
const addMerch = () => {
|
||||||
|
router.push({ name: 'addMerch' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSearch = computed({
|
||||||
|
get() {
|
||||||
|
return props.modelValue
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const selectedLabelUuids = computed({
|
||||||
|
get() {
|
||||||
|
return props.labelUuids
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit('update:labelUuids', value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const localValue = ref(props.modelValue)
|
const labelOptions = computed(() => {
|
||||||
|
return store.labels.map((label) => ({
|
||||||
watch(
|
label: label.name,
|
||||||
() => props.modelValue,
|
value: label.label_uuid,
|
||||||
(newVal) => {
|
}))
|
||||||
localValue.value = newVal
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(localValue, (newVal) => {
|
|
||||||
emit('update:modelValue', newVal)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<n-button type="primary" class="toolbar-item" @click="addMerch"> Add merch </n-button>
|
<n-button type="primary" class="toolbar-item" @click="addMerch"> Add merch</n-button>
|
||||||
|
|
||||||
<div class="search-wrapper toolbar-item">
|
<div class="search-wrapper toolbar-item">
|
||||||
<n-input v-model:value="localValue" placeholder="Search..." clearable />
|
<n-input v-model:value="localSearch" placeholder="Search..." clearable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="selector toolbar-item">
|
<div class="selector toolbar-item">
|
||||||
|
|
@ -49,13 +60,12 @@ watch(localValue, (newVal) => {
|
||||||
placeholder="Select label"
|
placeholder="Select label"
|
||||||
clearable
|
clearable
|
||||||
multiple
|
multiple
|
||||||
v-model:value="value"
|
v-model:value="selectedLabelUuids"
|
||||||
:options="options"
|
:options="labelOptions"
|
||||||
class="mobile-full-width" />
|
class="mobile-full-width"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useChartsApi } from '@/api/charts.js'
|
||||||
import EditLink from '@/views/DetailsView/EditLink.vue'
|
import EditLink from '@/views/DetailsView/EditLink.vue'
|
||||||
import CopyToClipboard from '@/components/CopyToClipboard.vue'
|
import CopyToClipboard from '@/components/CopyToClipboard.vue'
|
||||||
import DetailsViewImages from '@/views/DetailsView/DetailsViewImages.vue'
|
import DetailsViewImages from '@/views/DetailsView/DetailsViewImages.vue'
|
||||||
|
import AttachLabel from '@/views/DetailsView/AttachLabel.vue'
|
||||||
|
|
||||||
const { getMerchDetails, deleteMerch } = useMerchApi()
|
const { getMerchDetails, deleteMerch } = useMerchApi()
|
||||||
const { getDistinctPrices } = useChartsApi()
|
const { getDistinctPrices } = useChartsApi()
|
||||||
|
|
@ -107,6 +108,7 @@ onMounted(() => {
|
||||||
<div>
|
<div>
|
||||||
<p><strong>Uuid:</strong> {{ merchDetails.merch_uuid }}</p>
|
<p><strong>Uuid:</strong> {{ merchDetails.merch_uuid }}</p>
|
||||||
<p><strong>Name:</strong> {{ merchDetails.name }}</p>
|
<p><strong>Name:</strong> {{ merchDetails.name }}</p>
|
||||||
|
<AttachLabel :merch-uuid="props.merch_uuid" />
|
||||||
</div>
|
</div>
|
||||||
<DetailsViewImages :merch-uuid="merchDetails.merch_uuid"/>
|
<DetailsViewImages :merch-uuid="merchDetails.merch_uuid"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
147
src/views/DetailsView/AttachLabel.vue
Normal file
147
src/views/DetailsView/AttachLabel.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore.js'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import LabelTemplate from '@/components/LabelTemplate.vue'
|
||||||
|
|
||||||
|
const store = useLabelsStore()
|
||||||
|
const messages = useMessage()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
merchUuid: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachedLabels = ref([])
|
||||||
|
const selectedLabelUuids = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const labelOptions = computed(() => {
|
||||||
|
const attachedUuids = new Set(attachedLabels.value.map(l => l.label_uuid))
|
||||||
|
return store.labels
|
||||||
|
.filter(label => !attachedUuids.has(label.label_uuid))
|
||||||
|
.map(label => ({
|
||||||
|
label: label.name,
|
||||||
|
value: label.label_uuid,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchAttachedLabels = async () => {
|
||||||
|
if (!props.merchUuid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await store.getMerchLabels(props.merchUuid)
|
||||||
|
const uuids = response.data || []
|
||||||
|
|
||||||
|
if (!Array.isArray(uuids)) {
|
||||||
|
attachedLabels.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap = new Map()
|
||||||
|
for (const label of store.labels) {
|
||||||
|
labelMap.set(label.label_uuid, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachedLabels.value = uuids
|
||||||
|
.map(uuid => labelMap.get(uuid))
|
||||||
|
.filter(Boolean)
|
||||||
|
} catch (error) {
|
||||||
|
messages.error('Failed to load attached labels: ' + error)
|
||||||
|
attachedLabels.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (!props.merchUuid) {
|
||||||
|
messages.error('Merch UUID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedLabelUuids.value.length === 0) {
|
||||||
|
messages.warning('No labels selected')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
selectedLabelUuids.value.map(labelUuid =>
|
||||||
|
store.attachLabel(props.merchUuid, labelUuid)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messages.success('Label(s) attached successfully')
|
||||||
|
selectedLabelUuids.value = []
|
||||||
|
await fetchAttachedLabels()
|
||||||
|
} catch (error) {
|
||||||
|
messages.error('Failed to attach label(s): ' + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetach = async (labelUuid) => {
|
||||||
|
if (!props.merchUuid) {
|
||||||
|
messages.error('Merch UUID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.detachLabel(props.merchUuid, labelUuid)
|
||||||
|
messages.success('Label detached successfully')
|
||||||
|
await fetchAttachedLabels()
|
||||||
|
} catch (error) {
|
||||||
|
messages.error('Failed to detach label: ' + error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAttachedLabels()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p><strong>Select labels to attach:</strong></p>
|
||||||
|
<div class="label-row">
|
||||||
|
<n-select
|
||||||
|
v-model:value="selectedLabelUuids"
|
||||||
|
multiple
|
||||||
|
:options="labelOptions"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleAttach"
|
||||||
|
:disabled="selectedLabelUuids.length === 0 || loading"
|
||||||
|
style="width: 100px; margin-left: 12px"
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>Attached labels. Click label to detach it.</strong></p>
|
||||||
|
<div v-if="attachedLabels.length > 0" class="mt-10 label-row">
|
||||||
|
<LabelTemplate
|
||||||
|
v-for="label in attachedLabels"
|
||||||
|
:key="label.label_uuid"
|
||||||
|
:text="label.name"
|
||||||
|
:color="label.color"
|
||||||
|
:bg_color="label.bg_color"
|
||||||
|
@click="handleDetach(label.label_uuid)"
|
||||||
|
style="cursor: pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-20">
|
||||||
|
<router-link :to="{ name: 'labels' }">
|
||||||
|
<n-button type="primary">
|
||||||
|
Manage labels
|
||||||
|
</n-button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
@ -54,37 +54,21 @@ function onFileInputChange(event) {
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload({ fileList: newFileList }) {
|
async function fetchImage(bustCache = false) {
|
||||||
const file = newFileList[newFileList.length - 1]
|
|
||||||
try {
|
try {
|
||||||
await uploadImage(props.merchUuid, file.file)
|
let imgUrl = getImageUrl(props.merchUuid, 'full')
|
||||||
|
|
||||||
const { imgUrl } = await getImageUrl(props.merchUuid, 'full')
|
if (bustCache) {
|
||||||
|
const separator = imgUrl.includes('?') ? '&' : '?'
|
||||||
message.success('Image uploaded successfully.')
|
imgUrl += `${separator}_t=${Date.now()}`
|
||||||
|
}
|
||||||
fileList.value = [
|
|
||||||
{
|
|
||||||
name: file.name,
|
|
||||||
url: imgUrl,
|
|
||||||
status: 'finished',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
} catch (error) {
|
|
||||||
message.error('Upload error: ' + (error.message || 'Unknown error.'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const imgUrl = getImageUrl(props.merchUuid, 'full');
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image()
|
||||||
img.src = imgUrl;
|
img.src = imgUrl
|
||||||
img.onload = () => resolve(imgUrl);
|
img.onload = () => resolve(imgUrl)
|
||||||
img.onerror = () => reject(new Error('Image not found'));
|
img.onerror = () => reject(new Error('Image not found'))
|
||||||
});
|
})
|
||||||
|
|
||||||
fileList.value = [
|
fileList.value = [
|
||||||
{
|
{
|
||||||
|
|
@ -92,13 +76,28 @@ onMounted(async () => {
|
||||||
url: imgUrl,
|
url: imgUrl,
|
||||||
status: 'finished',
|
status: 'finished',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fileList.value = [];
|
fileList.value = []
|
||||||
if (!error.message.includes('404')) {
|
if (!error.message.includes('404')) {
|
||||||
console.error('Error getting image: ', error);
|
console.error('Error getting image:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload({ fileList: newFileList }) {
|
||||||
|
const file = newFileList[newFileList.length - 1]
|
||||||
|
try {
|
||||||
|
await uploadImage(props.merchUuid, file.file)
|
||||||
|
message.success('Image uploaded successfully.')
|
||||||
|
await fetchImage(true)
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Upload error: ' + (error.message || 'Unknown error.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchImage(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
const showConfirmDelete = ref(false)
|
const showConfirmDelete = ref(false)
|
||||||
|
|
|
||||||
78
src/views/LabelsView.vue
Normal file
78
src/views/LabelsView.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script setup>
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore.js'
|
||||||
|
import LabelCard from '@/views/LabelsView/LabelCard.vue'
|
||||||
|
import { NModal, useMessage } from 'naive-ui'
|
||||||
|
import LabelForm from '@/views/LabelsView/LabelForm.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import ScrollToTopButton from '@/components/ScrollToTopButton.vue'
|
||||||
|
|
||||||
|
const store = useLabelsStore()
|
||||||
|
const messages = useMessage()
|
||||||
|
|
||||||
|
const editForm = ref({
|
||||||
|
name: '',
|
||||||
|
color: '#EF2D56FF',
|
||||||
|
bg_color: '#FFFFFF',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const name = editForm.value.name?.trim() || 'preview'
|
||||||
|
|
||||||
|
await store.createLabel({
|
||||||
|
name,
|
||||||
|
color: editForm.value.color,
|
||||||
|
bg_color: editForm.value.bg_color,
|
||||||
|
})
|
||||||
|
|
||||||
|
editForm.value = {
|
||||||
|
name: '',
|
||||||
|
color: '#EF2D56FF',
|
||||||
|
bg_color: '#FFFFFF',
|
||||||
|
}
|
||||||
|
|
||||||
|
showCreateModal.value = false
|
||||||
|
messages.success('Label created.')
|
||||||
|
} catch (error) {
|
||||||
|
messages.error(error)
|
||||||
|
console.error('Failed to create label in component:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
|
||||||
|
const cancelCreate = () => {
|
||||||
|
showCreateModal.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3>Manage labels</h3>
|
||||||
|
<n-divider title-placement="left">Create label</n-divider>
|
||||||
|
<n-modal v-model:show="showCreateModal" preset="dialog" title="Create new label">
|
||||||
|
<template #default>
|
||||||
|
<p>Enter new values to create label.</p>
|
||||||
|
<LabelForm :edit-form="editForm" />
|
||||||
|
</template>
|
||||||
|
<template #action>
|
||||||
|
<n-button @click="handleCreate" type="primary">Create</n-button>
|
||||||
|
<n-button @click="cancelCreate">Cancel</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
<div class="mt-10 c-center">
|
||||||
|
<n-button type="primary" @click="showCreateModal=true" :loading="false" class="w360">
|
||||||
|
Create Label
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-divider title-placement="left">Current labels</n-divider>
|
||||||
|
<h4 class="text-center">Tip: click on a record to edit / delete label.</h4>
|
||||||
|
|
||||||
|
<div v-for="label in store.labels" :key="label.label_uuid">
|
||||||
|
<LabelCard :label-uuid="label.label_uuid" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTopButton />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
143
src/views/LabelsView/LabelCard.vue
Normal file
143
src/views/LabelsView/LabelCard.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NModal, useMessage } from 'naive-ui'
|
||||||
|
import { useLabelsStore } from '@/stores/labelsStore.js'
|
||||||
|
import LabelTemplate from '@/components/LabelTemplate.vue'
|
||||||
|
import LabelDotTemplate from '@/components/LabelDotTemplate.vue'
|
||||||
|
import LabelForm from '@/views/LabelsView/LabelForm.vue'
|
||||||
|
|
||||||
|
const store = useLabelsStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labelUuid: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const labelData = computed(() => {
|
||||||
|
return store.labels.find((label) => label.label_uuid === props.labelUuid)
|
||||||
|
})
|
||||||
|
|
||||||
|
const editForm = ref({})
|
||||||
|
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
|
||||||
|
const editLabelRecordHandler = () => {
|
||||||
|
if (!labelData.value) return
|
||||||
|
editForm.value = { ...labelData.value }
|
||||||
|
showEditModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
showEditModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await store.updateLabel(editForm.value.label_uuid, {
|
||||||
|
name: editForm.value.name,
|
||||||
|
color: editForm.value.color,
|
||||||
|
bg_color: editForm.value.bg_color,
|
||||||
|
})
|
||||||
|
showEditModal.value = false
|
||||||
|
message.success('Label updated successfully.')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = ref(false)
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirmDelete.value) return
|
||||||
|
try {
|
||||||
|
await store.deleteLabel(props.labelUuid)
|
||||||
|
showEditModal.value = false
|
||||||
|
message.success('Label deleted successfully.')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//на случай изменений извне, хотя таких быть не должно
|
||||||
|
watch(
|
||||||
|
() => props.labelData,
|
||||||
|
(newVal) => {
|
||||||
|
if (showEditModal.value) return
|
||||||
|
Object.assign(editForm.value, newVal)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-list hoverable clickable class="bottom-border">
|
||||||
|
<n-list-item @click="editLabelRecordHandler">
|
||||||
|
<div class="label-container">
|
||||||
|
<div>
|
||||||
|
<n-thing>Name: {{ labelData.name }}</n-thing>
|
||||||
|
<n-thing>UUID: {{ labelData.label_uuid }}</n-thing>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="label-column">
|
||||||
|
<n-thing>
|
||||||
|
<div class="label-row">
|
||||||
|
<LabelDotTemplate :color="labelData.color" :bg_color="labelData.bg_color" />
|
||||||
|
|
||||||
|
<LabelTemplate
|
||||||
|
:text="labelData.name"
|
||||||
|
:color="labelData.color"
|
||||||
|
:bg_color="labelData.bg_color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n-thing>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-list-item>
|
||||||
|
</n-list>
|
||||||
|
|
||||||
|
<n-modal v-model:show="showEditModal" preset="dialog" title="Manage label record">
|
||||||
|
<template #default>
|
||||||
|
<p>Enter new values to update. Or hit delete button to delete record.</p>
|
||||||
|
<LabelForm :edit-form="editForm" />
|
||||||
|
</template>
|
||||||
|
<template #action>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<n-checkbox v-model:checked="confirmDelete"> Check to confirm delete </n-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="buttons-row">
|
||||||
|
<n-button @click="handleUpdate" type="warning">Update</n-button>
|
||||||
|
<n-button @click="handleDelete" type="error" :disabled="!confirmDelete">Delete</n-button>
|
||||||
|
<n-button @click="cancelEdit">Cancel</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bottom-border {
|
||||||
|
border-bottom: rgba(24, 160, 88, 0.25) solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row,
|
||||||
|
.buttons-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons-row {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/views/LabelsView/LabelForm.vue
Normal file
36
src/views/LabelsView/LabelForm.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!-- LabelForm.vue -->
|
||||||
|
<script setup>
|
||||||
|
import LabelDotTemplate from '@/components/LabelDotTemplate.vue'
|
||||||
|
import LabelTemplate from '@/components/LabelTemplate.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
editForm: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="label-row label-preview">
|
||||||
|
<span>Preview: </span>
|
||||||
|
<LabelDotTemplate :color="editForm.color" :bg_color="editForm.bg_color" />
|
||||||
|
<LabelTemplate
|
||||||
|
:text="editForm.name || 'preview'"
|
||||||
|
:color="editForm.color"
|
||||||
|
:bg_color="editForm.bg_color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-form :model="editForm" label-placement="left" label-width="100">
|
||||||
|
<n-form-item label="Name" path="name">
|
||||||
|
<n-input v-model:value="editForm.name" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="Border and text color" path="color">
|
||||||
|
<n-color-picker v-model:value="editForm.color" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="Background color" path="bg_color">
|
||||||
|
<n-color-picker v-model:value="editForm.bg_color" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
|
@ -2,16 +2,23 @@
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/authStore.js'
|
import { useAuthStore } from '@/stores/authStore.js'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
const store = useAuthStore()
|
const store = useAuthStore()
|
||||||
|
const messages = useMessage()
|
||||||
|
|
||||||
const { activeTab } = storeToRefs(store)
|
const { activeTab } = storeToRefs(store)
|
||||||
|
|
||||||
const signInEmail = ref('')
|
const signInEmail = ref('')
|
||||||
const signInPassword = ref('')
|
const signInPassword = ref('')
|
||||||
|
|
||||||
const onSignIn = () => {
|
const onSignIn = async () => {
|
||||||
store.login(signInEmail.value, signInPassword.value)
|
try{
|
||||||
|
await store.login(signInEmail.value, signInPassword.value)
|
||||||
|
messages.success('Login success')
|
||||||
|
} catch (error) {
|
||||||
|
messages.error("Login error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const signUp = reactive({
|
const signUp = reactive({
|
||||||
|
|
@ -20,8 +27,35 @@ const signUp = reactive({
|
||||||
reenterPassword: '',
|
reenterPassword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSignUp = () => {
|
const onSignUp = async () => {
|
||||||
store.register(signUp.email, signUp.password)
|
if (!signUp.email.trim()) {
|
||||||
|
messages.error('Email is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(signUp.email)) {
|
||||||
|
messages.error('Please enter a valid email address')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signUp.password.trim()) {
|
||||||
|
messages.error('Password is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signUp.password !== signUp.reenterPassword) {
|
||||||
|
messages.error('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try{
|
||||||
|
await store.register(signUp.email, signUp.password)
|
||||||
|
messages.success('Register success')
|
||||||
|
activeTab.value = 'signin'
|
||||||
|
} catch (error) {
|
||||||
|
messages.error("Register error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -43,7 +77,7 @@ const onSignUp = () => {
|
||||||
<n-form-item-row label="Password">
|
<n-form-item-row label="Password">
|
||||||
<n-input v-model:value="signInPassword" type="password" show-password-on="click" />
|
<n-input v-model:value="signInPassword" type="password" show-password-on="click" />
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
<n-button type="primary" block secondary strong attr-type="submit" @click="onSignIn"> Sign In</n-button>
|
<n-button type="primary" block secondary strong attr-type="submit"> Sign In</n-button>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
||||||
|
|
@ -58,7 +92,7 @@ const onSignUp = () => {
|
||||||
<n-form-item-row label="Reenter Password">
|
<n-form-item-row label="Reenter Password">
|
||||||
<n-input type="password" v-model:value="signUp.reenterPassword" show-password-on="click" />
|
<n-input type="password" v-model:value="signUp.reenterPassword" show-password-on="click" />
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
<n-button type="primary" block secondary strong attr-type="submit" @click="onSignUp">Sign up</n-button>
|
<n-button type="primary" block secondary strong attr-type="submit">Sign up</n-button>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
|
|
|
||||||
|
|
@ -14,27 +14,26 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card :bordered="false" title="Main">
|
<n-divider title-placement="left">Main</n-divider>
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item>
|
<n-list-item>
|
||||||
<n-thing title="Email" content-style="margin-top: 10px;">
|
<n-thing title="Email" class="mt-10">
|
||||||
{{ userData?.email || '---' }}
|
{{ userData?.email || '---' }}
|
||||||
</n-thing>
|
</n-thing>
|
||||||
</n-list-item>
|
</n-list-item>
|
||||||
|
|
||||||
<n-list-item>
|
<n-list-item>
|
||||||
<n-thing title="Username" content-style="margin-top: 10px;">
|
<n-thing title="Username" class="mt-10">
|
||||||
{{ userData?.username || '---' }}
|
{{ userData?.username || '---' }}
|
||||||
</n-thing>
|
</n-thing>
|
||||||
</n-list-item>
|
</n-list-item>
|
||||||
|
|
||||||
<n-list-item>
|
<n-list-item>
|
||||||
<n-thing title="Created At" content-style="margin-top: 10px;">
|
<n-thing title="Created At" class="mt-10">
|
||||||
{{ userData?.created_at || '---' }}
|
{{ userData?.created_at || '---' }}
|
||||||
</n-thing>
|
</n-thing>
|
||||||
</n-list-item>
|
</n-list-item>
|
||||||
</n-list>
|
</n-list>
|
||||||
</n-card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,16 @@ const onLogout = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card :bordered="false" title="Session">
|
<n-divider title-placement="left">Session</n-divider>
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item>
|
<n-list-item>
|
||||||
<n-thing title="Session id" content-style="margin-top: 10px;">
|
<n-thing title="Session id" class="mt-10">
|
||||||
{{ currentSession?.uuid || '---' }}
|
{{ currentSession?.uuid || '---' }}
|
||||||
</n-thing>
|
</n-thing>
|
||||||
</n-list-item>
|
</n-list-item>
|
||||||
|
|
||||||
<n-list-item>
|
<n-list-item>
|
||||||
<n-thing title="Expires at" content-style="margin-top: 10px;">
|
<n-thing title="Expires at" class="mt-10">
|
||||||
{{ formattedDate }}
|
{{ formattedDate }}
|
||||||
</n-thing>
|
</n-thing>
|
||||||
</n-list-item>
|
</n-list-item>
|
||||||
|
|
@ -52,7 +52,6 @@ const onLogout = () => {
|
||||||
<n-button type="info" class="center-button" @click="onLogout">Log out</n-button>
|
<n-button type="info" class="center-button" @click="onLogout">Log out</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-list>
|
</n-list>
|
||||||
</n-card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
20
src/views/ZeroPricesView.vue
Normal file
20
src/views/ZeroPricesView.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup>
|
||||||
|
import ScrollToTopButton from '@/components/ScrollToTopButton.vue'
|
||||||
|
import TargetZeroesTab from '@/views/ZeroPricesView/TargetZeroesTab.vue'
|
||||||
|
import PeriodSelectTab from '@/views/ZeroPricesView/PeriodSelectTab.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-tabs type="line" animated>
|
||||||
|
<n-tab-pane name="Target zeroes" tab="Target zeroes">
|
||||||
|
<TargetZeroesTab />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane name="Period select" tab="Period select">
|
||||||
|
<PeriodSelectTab />
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
<ScrollToTopButton />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
54
src/views/ZeroPricesView/PeriodSelectTab.vue
Normal file
54
src/views/ZeroPricesView/PeriodSelectTab.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useZeroPrices } from '@/api/zeroPrices.js'
|
||||||
|
|
||||||
|
const range = ref(null)
|
||||||
|
|
||||||
|
const setTodayRange = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
range.value = [start.getTime(), end.getTime()]
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTodayRange()
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRFCtime = (timestamp) => {
|
||||||
|
return new Date(timestamp).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deleteZeroPricesPeriod } = useZeroPrices()
|
||||||
|
|
||||||
|
const deleteEnabled = computed(() => {
|
||||||
|
return range.value === null
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletePeriod = async () => {
|
||||||
|
if (range.value !== null) {
|
||||||
|
const start = toRFCtime(range.value[0])
|
||||||
|
const end = toRFCtime(range.value[1])
|
||||||
|
await deleteZeroPricesPeriod(start, end)
|
||||||
|
} else {
|
||||||
|
console.log('Delete period select zero prices error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-date-picker
|
||||||
|
v-model:value="range"
|
||||||
|
type="datetimerange"
|
||||||
|
format="HH:mm:ss dd-MM-yyyy"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<div class="button-container-center">
|
||||||
|
<n-button class="center-button w360" type="error" :disabled="deleteEnabled" @click="deletePeriod">Delete</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
71
src/views/ZeroPricesView/TargetZeroesTab.vue
Normal file
71
src/views/ZeroPricesView/TargetZeroesTab.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useZeroPrices } from '@/api/zeroPrices.js'
|
||||||
|
import ZeroPriceCard from '@/views/ZeroPricesView/ZeroPriceCard.vue'
|
||||||
|
import ZeroPricesToolbar from '@/views/ZeroPricesView/ZeroPricesToolbar.vue'
|
||||||
|
|
||||||
|
const { getZeroPrices } = useZeroPrices()
|
||||||
|
|
||||||
|
const zeroPrices = ref([])
|
||||||
|
const toDelete = ref([])
|
||||||
|
|
||||||
|
const handleToggle = ({ id, merch_uuid, checked }) => {
|
||||||
|
if (checked) {
|
||||||
|
toDelete.value.push({ id, merch_uuid });
|
||||||
|
} else {
|
||||||
|
toDelete.value = toDelete.value.filter(item => item.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
toDelete.value = zeroPrices.value.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
merch_uuid: item.merch_uuid
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchZeroPrices = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getZeroPrices()
|
||||||
|
zeroPrices.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleted = () => {
|
||||||
|
toDelete.value = []
|
||||||
|
fetchZeroPrices()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchZeroPrices()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="zeroPrices.length === 0">
|
||||||
|
<n-h2 class="text-center">Zero prices</n-h2>
|
||||||
|
<n-h3 class="text-center">No data</n-h3>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="sticky-search-container">
|
||||||
|
<ZeroPricesToolbar
|
||||||
|
:selected="toDelete"
|
||||||
|
@deleted="handleDeleted"
|
||||||
|
@selectAll="handleSelectAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-for="item in zeroPrices" :key="item.created_at">
|
||||||
|
<ZeroPriceCard
|
||||||
|
:zero-price="item"
|
||||||
|
@toggle="handleToggle"
|
||||||
|
:checked="toDelete.some(t => t.id === item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
71
src/views/ZeroPricesView/ZeroPriceCard.vue
Normal file
71
src/views/ZeroPricesView/ZeroPriceCard.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { originColors } from '@/services/colors.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
zeroPrice: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
checked: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['toggle'])
|
||||||
|
|
||||||
|
const handleCheckboxChange = (newValue) => {
|
||||||
|
emit('toggle', {
|
||||||
|
id: props.zeroPrice.id,
|
||||||
|
merch_uuid: props.zeroPrice.merch_uuid,
|
||||||
|
checked: newValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentOriginColor = computed(() => {
|
||||||
|
return originColors[props.zeroPrice.origin] || '#fff';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="zeroPriceCard mt-10">
|
||||||
|
<n-grid responsive="screen" item-responsive cols="4" :x-gap="16" :y-gap="16" class="shift">
|
||||||
|
<n-gi>
|
||||||
|
<n-checkbox :checked="checked" @update:checked="handleCheckboxChange">
|
||||||
|
<strong>Delete</strong>
|
||||||
|
</n-checkbox>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi><strong>Name:</strong> {{ props.zeroPrice.name }}</n-gi>
|
||||||
|
<n-gi><strong>Created:</strong> {{ props.zeroPrice.created_at }}</n-gi>
|
||||||
|
<n-gi
|
||||||
|
><strong>Origin:</strong>
|
||||||
|
<span class="bordered" :style="{ borderColor: currentOriginColor }">
|
||||||
|
{{ props.zeroPrice.origin }}
|
||||||
|
</span>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.zeroPriceCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bordered {
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px;
|
||||||
|
margin: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
src/views/ZeroPricesView/ZeroPricesToolbar.vue
Normal file
56
src/views/ZeroPricesView/ZeroPricesToolbar.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<script setup>
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { useZeroPrices } from '@/api/zeroPrices.js'
|
||||||
|
const props = defineProps({
|
||||||
|
selected: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = useMessage()
|
||||||
|
const { deleteZeroPrices } = useZeroPrices()
|
||||||
|
const emit = defineEmits(['deleted', 'selectAll'])
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteZeroPrices(props.selected)
|
||||||
|
messages.success("Selected zero prices deleted")
|
||||||
|
emit('deleted')
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
messages.error("Error deleting selected prices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = async () => {
|
||||||
|
emit('selectAll')
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toolbar button-container-evenly padding-lr-30">
|
||||||
|
<div v-if="props.selected.length === 0" class="toolbar-item">
|
||||||
|
<span>Select records to delete</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="toolbar-item">
|
||||||
|
{{ props.selected.length }} items selected
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-item">
|
||||||
|
<span @click="handleSelectAll">Click here to select all</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-item">
|
||||||
|
<n-button
|
||||||
|
type="error"
|
||||||
|
:disabled="props.selected.length === 0"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
Delete Selected
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue