Initial commit from monorepo
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
46
.storybook/main.ts
Normal file
46
.storybook/main.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../app/components/**/*.stories.@(js|ts)'],
|
||||||
|
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/vue3-vite',
|
||||||
|
options: {}
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: false
|
||||||
|
},
|
||||||
|
viteFinal: async (baseConfig) => {
|
||||||
|
const projectRoot = path.resolve(__dirname, '..')
|
||||||
|
baseConfig.resolve = baseConfig.resolve || {}
|
||||||
|
baseConfig.resolve.alias = {
|
||||||
|
...baseConfig.resolve.alias,
|
||||||
|
'~': path.resolve(__dirname, '../app'),
|
||||||
|
'@': path.resolve(__dirname, '../app'),
|
||||||
|
'@graphql-typed-document-node/core': path.resolve(__dirname, './shims/graphql-typed-document-node-core.ts')
|
||||||
|
}
|
||||||
|
baseConfig.plugins = baseConfig.plugins || []
|
||||||
|
baseConfig.plugins.push(vue())
|
||||||
|
baseConfig.root = projectRoot
|
||||||
|
baseConfig.server = {
|
||||||
|
...(baseConfig.server || {}),
|
||||||
|
fs: {
|
||||||
|
...(baseConfig.server?.fs || {}),
|
||||||
|
allow: Array.from(
|
||||||
|
new Set([
|
||||||
|
...(baseConfig.server?.fs?.allow || []),
|
||||||
|
projectRoot
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baseConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
23
.storybook/preview.ts
Normal file
23
.storybook/preview.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Preview } from '@storybook/vue3'
|
||||||
|
import { setup } from '@storybook/vue3'
|
||||||
|
|
||||||
|
import '../app/assets/css/tailwind.css'
|
||||||
|
|
||||||
|
setup((app) => {
|
||||||
|
app.config.globalProperties.$t = (key: string) => key
|
||||||
|
app.config.globalProperties.$d = (value: any) => value
|
||||||
|
})
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
10
.storybook/shims/graphql-typed-document-node-core.ts
Normal file
10
.storybook/shims/graphql-typed-document-node-core.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Minimal runtime shim so Vite/Storybook can resolve generated GraphQL imports.
|
||||||
|
import type { DocumentNode } from 'graphql'
|
||||||
|
|
||||||
|
export type TypedDocumentNode<TResult = any, TVariables = Record<string, any>> = DocumentNode & {
|
||||||
|
__resultType?: TResult
|
||||||
|
__variablesType?: TVariables
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime placeholder; generated files import the symbol but do not use the value.
|
||||||
|
export const TypedDocumentNode = {} as unknown as TypedDocumentNode
|
||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
|
ENV PNPM_HOME=/pnpm
|
||||||
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
ARG INFISICAL_API_URL
|
||||||
|
ARG INFISICAL_CLIENT_ID
|
||||||
|
ARG INFISICAL_CLIENT_SECRET
|
||||||
|
ARG INFISICAL_PROJECT_ID
|
||||||
|
ARG INFISICAL_ENV
|
||||||
|
|
||||||
|
ENV INFISICAL_API_URL=$INFISICAL_API_URL \
|
||||||
|
INFISICAL_CLIENT_ID=$INFISICAL_CLIENT_ID \
|
||||||
|
INFISICAL_CLIENT_SECRET=$INFISICAL_CLIENT_SECRET \
|
||||||
|
INFISICAL_PROJECT_ID=$INFISICAL_PROJECT_ID \
|
||||||
|
INFISICAL_ENV=$INFISICAL_ENV
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN node scripts/load-secrets.mjs && . ./.env.infisical && pnpm run build
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
COPY --from=build /app/scripts ./scripts
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "node scripts/load-secrets.mjs && . ./.env.infisical && node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs"]
|
||||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
14
app/app.vue
Normal file
14
app/app.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
'data-theme': 'cmyk',
|
||||||
|
},
|
||||||
|
script: []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
72
app/assets/css/tailwind.css
Normal file
72
app/assets/css/tailwind.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "silk";
|
||||||
|
default: true;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: "light";
|
||||||
|
--color-base-100: oklch(97% 0.0035 67.78);
|
||||||
|
--color-base-200: oklch(95% 0.0081 61.42);
|
||||||
|
--color-base-300: oklch(90% 0.0081 61.42);
|
||||||
|
--color-base-content: oklch(40% 0.0081 61.42);
|
||||||
|
--color-primary: oklch(23.27% 0.0249 284.3);
|
||||||
|
--color-primary-content: oklch(94.22% 0.2505 117.44);
|
||||||
|
--color-secondary: oklch(23.27% 0.0249 284.3);
|
||||||
|
--color-secondary-content: oklch(73.92% 0.2135 50.94);
|
||||||
|
--color-accent: oklch(23.27% 0.0249 284.3);
|
||||||
|
--color-accent-content: oklch(88.92% 0.2061 189.9);
|
||||||
|
--color-neutral: oklch(20% 0 0);
|
||||||
|
--color-neutral-content: oklch(80% 0.0081 61.42);
|
||||||
|
--color-info: oklch(80.39% 0.1148 241.68);
|
||||||
|
--color-info-content: oklch(30.39% 0.1148 241.68);
|
||||||
|
--color-success: oklch(83.92% 0.0901 136.87);
|
||||||
|
--color-success-content: oklch(23.92% 0.0901 136.87);
|
||||||
|
--color-warning: oklch(83.92% 0.1085 80);
|
||||||
|
--color-warning-content: oklch(43.92% 0.1085 80);
|
||||||
|
--color-error: oklch(75.1% 0.1814 22.37);
|
||||||
|
--color-error-content: oklch(35.1% 0.1814 22.37);
|
||||||
|
--radius-selector: 2rem;
|
||||||
|
--radius-field: 1rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.28125rem;
|
||||||
|
--size-field: 0.28125rem;
|
||||||
|
--border: 0.5px;
|
||||||
|
--depth: 0;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "night";
|
||||||
|
default: false;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: "dark";
|
||||||
|
--color-base-100: oklch(20.768% 0.039 265.754);
|
||||||
|
--color-base-200: oklch(19.314% 0.037 265.754);
|
||||||
|
--color-base-300: oklch(17.86% 0.034 265.754);
|
||||||
|
--color-base-content: oklch(84.153% 0.007 265.754);
|
||||||
|
--color-primary: oklch(75.351% 0.138 232.661);
|
||||||
|
--color-primary-content: oklch(15.07% 0.027 232.661);
|
||||||
|
--color-secondary: oklch(68.011% 0.158 276.934);
|
||||||
|
--color-secondary-content: oklch(13.602% 0.031 276.934);
|
||||||
|
--color-accent: oklch(72.36% 0.176 350.048);
|
||||||
|
--color-accent-content: oklch(14.472% 0.035 350.048);
|
||||||
|
--color-neutral: oklch(27.949% 0.036 260.03);
|
||||||
|
--color-neutral-content: oklch(85.589% 0.007 260.03);
|
||||||
|
--color-info: oklch(68.455% 0.148 237.251);
|
||||||
|
--color-info-content: oklch(0% 0 0);
|
||||||
|
--color-success: oklch(78.452% 0.132 181.911);
|
||||||
|
--color-success-content: oklch(15.69% 0.026 181.911);
|
||||||
|
--color-warning: oklch(83.242% 0.139 82.95);
|
||||||
|
--color-warning-content: oklch(16.648% 0.027 82.95);
|
||||||
|
--color-error: oklch(71.785% 0.17 13.118);
|
||||||
|
--color-error-content: oklch(14.357% 0.034 13.118);
|
||||||
|
--radius-selector: 1rem;
|
||||||
|
--radius-field: 1rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 0.5px;
|
||||||
|
--depth: 0;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
21
app/components/BankSearchRussia.stories.ts
Normal file
21
app/components/BankSearchRussia.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './BankSearchRussia.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'BankSearchRussia',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
130
app/components/BankSearchRussia.vue
Normal file
130
app/components/BankSearchRussia.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="showDropdown = true"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Enter bank name or BIC..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dropdown with bank suggestions -->
|
||||||
|
<div
|
||||||
|
v-if="showDropdown && suggestions.length > 0"
|
||||||
|
class="absolute top-full left-0 right-0 z-50 bg-base-100 border border-base-300 rounded-box shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in suggestions"
|
||||||
|
:key="index"
|
||||||
|
@click="selectBank(suggestion)"
|
||||||
|
class="p-3 cursor-pointer hover:bg-base-200 transition-colors border-b border-base-300 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-base-content">{{ suggestion.value }}</div>
|
||||||
|
<div class="text-sm text-base-content/80">
|
||||||
|
BIC: {{ suggestion.data.bic }}
|
||||||
|
<span v-if="suggestion.data.correspondent_account">
|
||||||
|
• Corr. account: {{ suggestion.data.correspondent_account }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="suggestion.data.address" class="text-xs text-base-content/60 mt-1">
|
||||||
|
{{ suggestion.data.address.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div v-if="loading" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface BankData {
|
||||||
|
bankName: string
|
||||||
|
bik: string
|
||||||
|
correspondentAccount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: BankData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: BankData): void
|
||||||
|
(e: 'select', bank: any): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: () => ({
|
||||||
|
bankName: '',
|
||||||
|
bik: '',
|
||||||
|
correspondentAccount: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const suggestions = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
|
||||||
|
// Hide dropdown when clicking outside
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target?.closest('.relative')) {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInput = async () => {
|
||||||
|
if (query.value.length < 2) {
|
||||||
|
suggestions.value = []
|
||||||
|
showDropdown.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/bank', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': 'Token 8de30547fe1e228dd5d7289439b71f5a97cf7357'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: query.value,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
suggestions.value = data.suggestions || []
|
||||||
|
showDropdown.value = true
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DADATA Bank error:', error)
|
||||||
|
suggestions.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectBank = (bank: any) => {
|
||||||
|
query.value = bank.value
|
||||||
|
showDropdown.value = false
|
||||||
|
|
||||||
|
const bankData: BankData = {
|
||||||
|
bankName: bank.value,
|
||||||
|
bik: bank.data.bic,
|
||||||
|
correspondentAccount: bank.data.correspondent_account || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', bankData)
|
||||||
|
emit('select', bank)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
63
app/components/CabinetBreadcrumbs.vue
Normal file
63
app/components/CabinetBreadcrumbs.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="breadcrumbs text-sm">
|
||||||
|
<ul>
|
||||||
|
<li v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||||
|
<NuxtLink v-if="crumb.to" :to="crumb.to">{{ crumb.label }}</NuxtLink>
|
||||||
|
<span v-else>{{ crumb.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
const crumbs: Array<{ label: string; to?: string }> = []
|
||||||
|
|
||||||
|
// Always start with Cabinet
|
||||||
|
crumbs.push({ label: t('breadcrumbs.cabinet'), to: localePath('/clientarea') })
|
||||||
|
|
||||||
|
// Parse path segments after /clientarea
|
||||||
|
const segments = path.replace(/^\/[a-z]{2}\//, '/').replace('/clientarea', '').split('/').filter(Boolean)
|
||||||
|
|
||||||
|
const pathMap: Record<string, string> = {
|
||||||
|
orders: t('breadcrumbs.orders'),
|
||||||
|
addresses: t('breadcrumbs.addresses'),
|
||||||
|
profile: t('breadcrumbs.profile'),
|
||||||
|
team: t('breadcrumbs.team'),
|
||||||
|
kyc: t('breadcrumbs.kyc'),
|
||||||
|
offers: t('breadcrumbs.offers'),
|
||||||
|
new: t('breadcrumbs.new'),
|
||||||
|
russia: t('breadcrumbs.russia'),
|
||||||
|
ai: t('breadcrumbs.ai'),
|
||||||
|
goods: t('breadcrumbs.goods'),
|
||||||
|
locations: t('breadcrumbs.locations'),
|
||||||
|
request: t('breadcrumbs.request'),
|
||||||
|
'company-switch': t('breadcrumbs.companySwitch'),
|
||||||
|
'debug-tokens': t('breadcrumbs.tokens'),
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPath = '/clientarea'
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i]
|
||||||
|
currentPath += `/${segment}`
|
||||||
|
const isLast = i === segments.length - 1
|
||||||
|
|
||||||
|
// Check if segment is UUID or ID
|
||||||
|
const isId = /^[0-9a-f-]{36}$/.test(segment) || /^\d+$/.test(segment)
|
||||||
|
|
||||||
|
const label = isId ? `#${segment.slice(0, 8)}...` : (pathMap[segment] || segment)
|
||||||
|
|
||||||
|
crumbs.push({
|
||||||
|
label,
|
||||||
|
to: isLast ? undefined : localePath(currentPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/CalcResultContent.stories.ts
Normal file
21
app/components/CalcResultContent.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './CalcResultContent.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'CalcResultContent',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
248
app/components/CalcResultContent.vue
Normal file
248
app/components/CalcResultContent.vue
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-10">
|
||||||
|
<Card padding="lg" class="border border-base-300">
|
||||||
|
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div v-if="pending" class="text-sm text-base-content/60">
|
||||||
|
Загрузка маршрутов...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="text-sm text-error">
|
||||||
|
Ошибка загрузки маршрутов: {{ error.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
|
||||||
|
<div v-if="productRouteOptions.length" class="space-y-10">
|
||||||
|
<div
|
||||||
|
v-for="(option, optionIndex) in productRouteOptions"
|
||||||
|
:key="option.sourceUuid || optionIndex"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
|
||||||
|
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="option.routes?.length" class="space-y-6">
|
||||||
|
<Card
|
||||||
|
v-for="(route, routeIndex) in option.routes"
|
||||||
|
:key="routeIndex"
|
||||||
|
padding="lg"
|
||||||
|
class="border border-base-300"
|
||||||
|
>
|
||||||
|
<Stack gap="4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
|
||||||
|
<Text tone="muted" size="sm">
|
||||||
|
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouteStagesList :stages="mapRouteStages(route)" />
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<RequestRoutesMap :routes="[route]" :height="240" />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text v-else tone="muted" size="sm">
|
||||||
|
Маршруты от источника не найдены.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!productRouteOptions.length && legacyRoutes.length">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Card
|
||||||
|
v-for="(route, routeIndex) in legacyRoutes"
|
||||||
|
:key="routeIndex"
|
||||||
|
padding="lg"
|
||||||
|
class="border border-base-300"
|
||||||
|
>
|
||||||
|
<Stack gap="4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
|
||||||
|
<Text tone="muted" size="sm">
|
||||||
|
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouteStagesList :stages="mapRouteStages(route)" />
|
||||||
|
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
|
||||||
|
<RequestRoutesMap :routes="[route]" :height="240" />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-sm text-base-content/60">
|
||||||
|
Маршруты не найдены. Возможно, нет связи между точками в графе.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
|
||||||
|
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
|
||||||
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const searchStore = useSearchStore()
|
||||||
|
|
||||||
|
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
||||||
|
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
||||||
|
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
|
||||||
|
|
||||||
|
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
||||||
|
const summaryMeta = computed(() => {
|
||||||
|
const meta: string[] = []
|
||||||
|
if (quantity.value) {
|
||||||
|
meta.push(`${quantity.value}`)
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine context (new flow: product + destination; legacy: from param)
|
||||||
|
const productUuid = computed(() => (route.query.productUuid as string) || searchStore.searchForm.productUuid)
|
||||||
|
const destinationUuid = computed(() => (route.query.locationUuid as string) || searchStore.searchForm.locationUuid)
|
||||||
|
const legacyFromUuid = computed(() => route.params.id as string | undefined)
|
||||||
|
|
||||||
|
type ProductRouteOption = {
|
||||||
|
sourceUuid?: string | null
|
||||||
|
sourceName?: string | null
|
||||||
|
sourceLat?: number | null
|
||||||
|
sourceLon?: number | null
|
||||||
|
distanceKm?: number | null
|
||||||
|
routes?: RoutePathType[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchProductRoutes = async () => {
|
||||||
|
if (!productUuid.value || !destinationUuid.value) return null
|
||||||
|
const { client } = useApolloClient('publicGeo')
|
||||||
|
const { default: gql } = await import('graphql-tag')
|
||||||
|
|
||||||
|
const query = gql`
|
||||||
|
query FindProductRoutes($productUuid: String!, $toUuid: String!, $limitSources: Int, $limitRoutes: Int) {
|
||||||
|
findProductRoutes(
|
||||||
|
productUuid: $productUuid
|
||||||
|
toUuid: $toUuid
|
||||||
|
limitSources: $limitSources
|
||||||
|
limitRoutes: $limitRoutes
|
||||||
|
) {
|
||||||
|
sourceUuid
|
||||||
|
sourceName
|
||||||
|
sourceLat
|
||||||
|
sourceLon
|
||||||
|
distanceKm
|
||||||
|
routes {
|
||||||
|
totalDistanceKm
|
||||||
|
totalTimeSeconds
|
||||||
|
stages {
|
||||||
|
fromUuid
|
||||||
|
fromName
|
||||||
|
fromLat
|
||||||
|
fromLon
|
||||||
|
toUuid
|
||||||
|
toName
|
||||||
|
toLat
|
||||||
|
toLon
|
||||||
|
distanceKm
|
||||||
|
travelTimeSeconds
|
||||||
|
transportType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const { data } = await client.query({
|
||||||
|
query,
|
||||||
|
variables: {
|
||||||
|
productUuid: productUuid.value,
|
||||||
|
toUuid: destinationUuid.value,
|
||||||
|
limitSources: 5,
|
||||||
|
limitRoutes: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchLegacyRoutes = async () => {
|
||||||
|
if (!legacyFromUuid.value || !destinationUuid.value) return null
|
||||||
|
const { client } = useApolloClient('publicGeo')
|
||||||
|
const { data } = await client.query({
|
||||||
|
query: FindRoutesDocument,
|
||||||
|
variables: {
|
||||||
|
fromUuid: legacyFromUuid.value,
|
||||||
|
toUuid: destinationUuid.value,
|
||||||
|
limit: 3
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: productRoutesData, pending, error } = await useAsyncData(
|
||||||
|
() => `product-routes-${productUuid.value}-${destinationUuid.value}-${legacyFromUuid.value || 'none'}`,
|
||||||
|
async () => {
|
||||||
|
// Prefer product-based routes; fallback to legacy if no product
|
||||||
|
if (productUuid.value && destinationUuid.value) {
|
||||||
|
return await fetchProductRoutes()
|
||||||
|
}
|
||||||
|
if (legacyFromUuid.value && destinationUuid.value) {
|
||||||
|
return await fetchLegacyRoutes()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
{ watch: [productUuid, destinationUuid, legacyFromUuid] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const productRouteOptions = computed(() => {
|
||||||
|
const options = productRoutesData.value?.findProductRoutes as ProductRouteOption[] | undefined
|
||||||
|
return options?.filter(Boolean) || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const legacyRoutes = computed(() => {
|
||||||
|
const data = productRoutesData.value?.findRoutes
|
||||||
|
if (!data) return []
|
||||||
|
return (data as (RoutePathType | null)[]).filter((r): r is RoutePathType => r !== null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
|
||||||
|
return (route.stages || [])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((stage, index) => ({
|
||||||
|
key: stage?.fromUuid || `stage-${index}`,
|
||||||
|
from: stage?.fromName || 'Начало',
|
||||||
|
to: stage?.toName || 'Конец',
|
||||||
|
distanceKm: stage?.distanceKm,
|
||||||
|
durationSeconds: stage?.travelTimeSeconds
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
const formatDistance = (km: number | null | undefined) => {
|
||||||
|
if (!km) return '0'
|
||||||
|
return Math.round(km).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number | null | undefined) => {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (hours > 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const remainingHours = hours % 24
|
||||||
|
return `${days}д ${remainingHours}ч`
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}ч ${minutes}м`
|
||||||
|
}
|
||||||
|
return `${minutes}м`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/CompanyCard.stories.ts
Normal file
21
app/components/CompanyCard.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './CompanyCard.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'CompanyCard',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
138
app/components/CompanyCard.vue
Normal file
138
app/components/CompanyCard.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="card bg-base-100 border border-base-300 shadow-sm transition-all duration-200 hover:scale-105"
|
||||||
|
:class="sizeClasses"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<!-- Company logo -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center text-primary-content font-bold"
|
||||||
|
:class="logoSizeClasses"
|
||||||
|
>
|
||||||
|
{{ getCompanyInitials(company.name) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="font-semibold text-base-content truncate" :class="nameSizeClasses">
|
||||||
|
{{ company.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-base-content/70 text-xs">
|
||||||
|
{{ company.country }} • {{ company.taxId }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Extra info -->
|
||||||
|
<div v-if="tripCount" class="mt-2">
|
||||||
|
<p class="text-xs text-primary font-medium">
|
||||||
|
{{ tripCount }} trips
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trust rating -->
|
||||||
|
<div class="flex-shrink-0 text-center">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<span class="font-semibold text-sm text-base-content">{{ getTrustRating(company) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/60">rating</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats (only for large size) -->
|
||||||
|
<div v-if="size === 'large'" class="mt-4 pt-4 border-t border-base-300">
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().totalTrips }}</p>
|
||||||
|
<p class="text-xs text-base-content/60">trips</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().totalWeight }}t</p>
|
||||||
|
<p class="text-xs text-base-content/60">carried</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-lg font-semibold text-base-content">{{ getCompanyStats().onTimePercentage }}%</p>
|
||||||
|
<p class="text-xs text-base-content/60">on time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
company: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
tripCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'medium', // small, medium, large
|
||||||
|
validator: value => ['small', 'medium', 'large'].includes(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeClasses = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return 'p-3'
|
||||||
|
case 'large': return 'p-6'
|
||||||
|
default: return 'p-4'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const logoSizeClasses = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return 'w-8 h-8 text-xs'
|
||||||
|
case 'large': return 'w-16 h-16 text-xl'
|
||||||
|
default: return 'w-12 h-12 text-sm'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const nameSizeClasses = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return 'text-sm'
|
||||||
|
case 'large': return 'text-lg'
|
||||||
|
default: return 'text-base'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCompanyInitials = (name) => {
|
||||||
|
if (!name) return '??'
|
||||||
|
|
||||||
|
const words = name.split(' ')
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return (words[0][0] + words[1][0]).toUpperCase()
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrustRating = (company) => {
|
||||||
|
// Generate pseudo-rating based on company name
|
||||||
|
const ratings = {
|
||||||
|
'Kenya Coffee Logistics Ltd': 4.8,
|
||||||
|
'Kenya Highland Transport': 4.6,
|
||||||
|
'Mombasa Express Cargo': 4.4,
|
||||||
|
'East Africa Shipping Lines': 4.7,
|
||||||
|
'Truck LLC': 4.5,
|
||||||
|
'Motor Depot #1': 4.3,
|
||||||
|
'RailTrans LLC': 4.6,
|
||||||
|
'Kenya Ports Authority': 4.9
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratings[company.name] || 4.2
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCompanyStats = () => {
|
||||||
|
// Demo stats stub
|
||||||
|
return {
|
||||||
|
totalTrips: props.tripCount || Math.floor(Math.random() * 50) + 10,
|
||||||
|
totalWeight: (props.tripCount || 25) * 25, // 25t per trip
|
||||||
|
onTimePercentage: Math.floor(Math.random() * 20) + 80 // 80-100%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/CompanySearchRussia.stories.ts
Normal file
21
app/components/CompanySearchRussia.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './CompanySearchRussia.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'CompanySearchRussia',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
137
app/components/CompanySearchRussia.vue
Normal file
137
app/components/CompanySearchRussia.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
@input="onInput"
|
||||||
|
@focus="showDropdown = true"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Enter organization name or INN..."
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dropdown with suggestions -->
|
||||||
|
<div
|
||||||
|
v-if="showDropdown && suggestions.length > 0"
|
||||||
|
class="absolute top-full left-0 right-0 z-50 bg-base-100 border border-base-300 rounded-box shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(suggestion, index) in suggestions"
|
||||||
|
:key="index"
|
||||||
|
@click="selectCompany(suggestion)"
|
||||||
|
class="p-3 cursor-pointer hover:bg-base-200 transition-colors border-b border-base-300 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-base-content">{{ suggestion.value }}</div>
|
||||||
|
<div class="text-sm text-base-content/80">
|
||||||
|
INN: {{ suggestion.data.inn }}
|
||||||
|
<span v-if="suggestion.data.kpp"> • KPP: {{ suggestion.data.kpp }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="suggestion.data.address" class="text-xs text-base-content/60 mt-1">
|
||||||
|
{{ suggestion.data.address.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div v-if="loading" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<span class="loading loading-spinner loading-xs text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface CompanyData {
|
||||||
|
companyName: string
|
||||||
|
companyFullName: string
|
||||||
|
inn: string
|
||||||
|
kpp: string
|
||||||
|
ogrn: string
|
||||||
|
address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: CompanyData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:modelValue', value: CompanyData): void
|
||||||
|
(e: 'select', company: any): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: () => ({
|
||||||
|
companyName: '',
|
||||||
|
companyFullName: '',
|
||||||
|
inn: '',
|
||||||
|
kpp: '',
|
||||||
|
ogrn: '',
|
||||||
|
address: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const suggestions = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showDropdown = ref(false)
|
||||||
|
|
||||||
|
// Hide dropdown when clicking outside
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target?.closest('.relative')) {
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInput = async () => {
|
||||||
|
if (query.value.length < 2) {
|
||||||
|
suggestions.value = []
|
||||||
|
showDropdown.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/party', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': 'Token 8de30547fe1e228dd5d7289439b71f5a97cf7357'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: query.value,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
suggestions.value = data.suggestions || []
|
||||||
|
showDropdown.value = true
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DADATA error:', error)
|
||||||
|
suggestions.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCompany = (company: any) => {
|
||||||
|
query.value = company.value
|
||||||
|
showDropdown.value = false
|
||||||
|
|
||||||
|
const companyData: CompanyData = {
|
||||||
|
companyName: company.value,
|
||||||
|
companyFullName: company.unrestricted_value,
|
||||||
|
inn: company.data.inn,
|
||||||
|
kpp: company.data.kpp || '',
|
||||||
|
ogrn: company.data.ogrn || '',
|
||||||
|
address: company.data.address?.value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', companyData)
|
||||||
|
emit('select', company)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
32
app/components/EmptyState.vue
Normal file
32
app/components/EmptyState.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 px-4">
|
||||||
|
<div class="text-6xl mb-4">{{ icon }}</div>
|
||||||
|
<h3 class="text-lg font-semibold text-base-content mb-2">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="text-base-content/60 text-center max-w-sm mb-6">{{ description }}</p>
|
||||||
|
<NuxtLink v-if="actionTo" :to="actionTo">
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<Icon v-if="actionIcon" :name="actionIcon" size="18" class="mr-2" />
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
<button v-else-if="actionLabel" class="btn btn-primary" @click="$emit('action')">
|
||||||
|
<Icon v-if="actionIcon" :name="actionIcon" size="18" class="mr-2" />
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actionLabel?: string
|
||||||
|
actionTo?: string
|
||||||
|
actionIcon?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
action: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
21
app/components/FooterPublic.stories.ts
Normal file
21
app/components/FooterPublic.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './FooterPublic.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'FooterPublic',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
9
app/components/FooterPublic.vue
Normal file
9
app/components/FooterPublic.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="bg-base-200 text-base-content/80 py-6">
|
||||||
|
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
21
app/components/GanttTimeline.stories.ts
Normal file
21
app/components/GanttTimeline.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './GanttTimeline.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'GanttTimeline',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
272
app/components/GanttTimeline.vue
Normal file
272
app/components/GanttTimeline.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<ClientOnly>
|
||||||
|
<Timeline
|
||||||
|
:groups="timelineGroups"
|
||||||
|
:items="timelineItems"
|
||||||
|
:viewportMin="viewportMin"
|
||||||
|
:viewportMax="viewportMax"
|
||||||
|
:minViewportDuration="1000 * 60 * 60 * 24 * 3"
|
||||||
|
:maxViewportDuration="1000 * 60 * 60 * 24 * 14"
|
||||||
|
:defaultViewportDuration="1000 * 60 * 60 * 24 * 7"
|
||||||
|
class="h-96"
|
||||||
|
@item-click="onTripClick"
|
||||||
|
@item-hover="onTripHover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-96 w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||||
|
<p class="text-base-content/60">{{ t('ganttTimeline.states.loading') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="mt-4 bg-base-200 rounded-box p-4">
|
||||||
|
<h4 class="font-medium text-sm text-base-content mb-3">{{ t('ganttTimeline.legend.title') }}</h4>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 bg-primary rounded"></div>
|
||||||
|
<span>{{ t('ganttTimeline.legend.auto') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 bg-purple-500 rounded"></div>
|
||||||
|
<span>{{ t('ganttTimeline.legend.sea') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 bg-orange-400 rounded"></div>
|
||||||
|
<span>{{ t('ganttTimeline.legend.rail') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 bg-success rounded"></div>
|
||||||
|
<span>{{ t('ganttTimeline.legend.services') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trip details modal -->
|
||||||
|
<div v-if="showTripModal" class="fixed inset-0 bg-base-content/50 flex items-center justify-center z-50" @click="closeTripModal">
|
||||||
|
<div class="bg-base-100 border border-base-300 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl" @click.stop>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">{{ t('ganttTimeline.modal.title') }}</h3>
|
||||||
|
<button @click="closeTripModal" class="text-base-content/60 hover:text-base-content">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTrip" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-base">{{ selectedTrip.trip.name }}</h4>
|
||||||
|
<p class="text-sm text-base-content/70">{{ selectedTrip.stage.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTrip.trip.company" class="card bg-base-200 border border-base-300 p-3">
|
||||||
|
<h5 class="font-medium text-sm mb-1">{{ t('ganttTimeline.modal.company.title') }}</h5>
|
||||||
|
<p class="text-sm">{{ selectedTrip.trip.company.name }}</p>
|
||||||
|
<p v-if="selectedTrip.trip.company.taxId" class="text-xs text-base-content/60">{{ t('ganttTimeline.modal.company.tax_id') }} {{ selectedTrip.trip.company.taxId }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-primary/10 border border-base-300 p-3">
|
||||||
|
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.dates.title') }}</h5>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div v-if="selectedTrip.trip.plannedLoadingDate">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_loading') }}</span>
|
||||||
|
{{ formatDisplayDate(selectedTrip.trip.plannedLoadingDate) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTrip.trip.actualLoadingDate">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_loading') }}</span>
|
||||||
|
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualLoadingDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTrip.trip.plannedUnloadingDate">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.planned_unloading') }}</span>
|
||||||
|
{{ formatDisplayDate(selectedTrip.trip.plannedUnloadingDate) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTrip.trip.actualUnloadingDate">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.dates.actual_unloading') }}</span>
|
||||||
|
<span class="text-success font-medium">{{ formatDisplayDate(selectedTrip.trip.actualUnloadingDate) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTrip.trip.plannedWeight || selectedTrip.trip.weightAtLoading || selectedTrip.trip.weightAtUnloading" class="card bg-warning/10 border border-base-300 p-3">
|
||||||
|
<h5 class="font-medium text-sm mb-2">{{ t('ganttTimeline.modal.weight.title') }}</h5>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div v-if="selectedTrip.trip.plannedWeight">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.planned') }}</span> {{ selectedTrip.trip.plannedWeight }} {{ t('ganttTimeline.modal.weight.unit') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTrip.trip.weightAtLoading">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_loading') }}</span> {{ selectedTrip.trip.weightAtLoading }} {{ t('ganttTimeline.modal.weight.unit') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTrip.trip.weightAtUnloading">
|
||||||
|
<span class="text-base-content/70">{{ t('ganttTimeline.modal.weight.at_unloading') }}</span> {{ selectedTrip.trip.weightAtUnloading }} {{ t('ganttTimeline.modal.weight.unit') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Timeline } from 'vue-timeline-chart'
|
||||||
|
import 'vue-timeline-chart/style.css'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
showLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showUnloading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Stage groups for timeline (only stages with trips)
|
||||||
|
const timelineGroups = computed(() => {
|
||||||
|
return props.stages
|
||||||
|
.filter(stage => stage.trips && stage.trips.length > 0)
|
||||||
|
.map(stage => ({
|
||||||
|
id: stage.uuid,
|
||||||
|
label: stage.name
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Timeline items (trips)
|
||||||
|
const timelineItems = computed(() => {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
props.stages.forEach(stage => {
|
||||||
|
stage.trips?.forEach(trip => {
|
||||||
|
// Date priority: actualLoadingDate > plannedLoadingDate > current date
|
||||||
|
const startDate = trip.actualLoadingDate || trip.plannedLoadingDate || new Date().toISOString()
|
||||||
|
|
||||||
|
// Date priority: actualUnloadingDate > plannedUnloadingDate > date + 1 day
|
||||||
|
const endDate = trip.actualUnloadingDate ||
|
||||||
|
trip.plannedUnloadingDate ||
|
||||||
|
new Date(new Date(startDate).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
const startTime = new Date(startDate).getTime()
|
||||||
|
const endTime = new Date(endDate).getTime()
|
||||||
|
|
||||||
|
// Format dates for tooltip
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return 'Not specified'
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
group: stage.uuid,
|
||||||
|
type: 'range',
|
||||||
|
start: startTime,
|
||||||
|
end: endTime,
|
||||||
|
label: trip.name,
|
||||||
|
tooltip: `
|
||||||
|
<div class="p-3">
|
||||||
|
<h4 class="font-semibold text-sm mb-2">${trip.name}</h4>
|
||||||
|
<div class="text-xs space-y-1">
|
||||||
|
<div><strong>Stage:</strong> ${stage.name}</div>
|
||||||
|
${trip.company?.name ? `<div><strong>Company:</strong> ${trip.company.name}</div>` : ''}
|
||||||
|
<div><strong>Planned load:</strong> ${formatDate(trip.plannedLoadingDate)}</div>
|
||||||
|
<div><strong>Planned unload:</strong> ${formatDate(trip.plannedUnloadingDate)}</div>
|
||||||
|
${trip.actualLoadingDate ? `<div><strong>Actual load:</strong> ${formatDate(trip.actualLoadingDate)}</div>` : ''}
|
||||||
|
${trip.actualUnloadingDate ? `<div><strong>Actual unload:</strong> ${formatDate(trip.actualUnloadingDate)}</div>` : ''}
|
||||||
|
${trip.plannedWeight ? `<div><strong>Planned weight:</strong> ${trip.plannedWeight} t</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
data: {
|
||||||
|
trip: trip,
|
||||||
|
stage: stage
|
||||||
|
},
|
||||||
|
cssVariables: {
|
||||||
|
'--item-background': getStageColor(stage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dynamic viewport based on data
|
||||||
|
const viewportMin = computed(() => {
|
||||||
|
if (timelineItems.value.length === 0) {
|
||||||
|
return new Date().getTime() - (30 * 24 * 60 * 60 * 1000) // one month back
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTime = Math.min(...timelineItems.value.map(item => item.start))
|
||||||
|
return minTime - (3 * 24 * 60 * 60 * 1000) // 3 days before earliest
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewportMax = computed(() => {
|
||||||
|
if (timelineItems.value.length === 0) {
|
||||||
|
return new Date().getTime() + (60 * 24 * 60 * 60 * 1000) // two months ahead
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTime = Math.max(...timelineItems.value.map(item => item.end))
|
||||||
|
return maxTime + (3 * 24 * 60 * 60 * 1000) // 3 days after latest
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// Modal reactive state
|
||||||
|
const selectedTrip = ref(null)
|
||||||
|
const showTripModal = ref(false)
|
||||||
|
|
||||||
|
// Timeline handlers
|
||||||
|
const onTripClick = (event) => {
|
||||||
|
if (event && event.data) {
|
||||||
|
selectedTrip.value = event.data
|
||||||
|
showTripModal.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTripHover = (event) => {
|
||||||
|
// Tooltip handled automatically via tooltip field
|
||||||
|
console.log('Trip hovered:', event?.data?.trip?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTripModal = () => {
|
||||||
|
showTripModal.value = false
|
||||||
|
selectedTrip.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date for modal display
|
||||||
|
const formatDisplayDate = (dateStr) => {
|
||||||
|
if (!dateStr) return 'Not specified'
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStageColor = (stage) => {
|
||||||
|
const colors = {
|
||||||
|
auto: '#3b82f6', // blue
|
||||||
|
sea: '#8b5cf6', // purple
|
||||||
|
rail: '#f97316', // orange
|
||||||
|
air: '#06b6d4', // cyan
|
||||||
|
service: '#10b981' // green
|
||||||
|
}
|
||||||
|
|
||||||
|
return colors[stage.transportType] || colors.service
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/GoodsContent.stories.ts
Normal file
21
app/components/GoodsContent.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './GoodsContent.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'GoodsContent',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
64
app/components/GoodsContent.vue
Normal file
64
app/components/GoodsContent.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<Stack gap="6">
|
||||||
|
<PageHeader
|
||||||
|
:title="$t('goods.title')"
|
||||||
|
:description="$t('goods.description')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="pending" class="flex items-center justify-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-else-if="error" variant="error">
|
||||||
|
<Stack gap="2">
|
||||||
|
<Heading :level="4" weight="semibold">{{ $t('goods.error.title') }}</Heading>
|
||||||
|
<Button @click="refresh()">{{ $t('goods.error.retry') }}</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="!productsData?.length"
|
||||||
|
:title="$t('goods.empty.title')"
|
||||||
|
:description="$t('goods.empty.description')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<ProductCard
|
||||||
|
v-for="product in productsData"
|
||||||
|
:key="product.uuid"
|
||||||
|
:product="product"
|
||||||
|
selectable
|
||||||
|
@select="selectProduct(product)"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
||||||
|
|
||||||
|
const searchStore = useSearchStore()
|
||||||
|
|
||||||
|
const { data, pending, error, refresh } = await useServerQuery('products', GetProductsDocument, {}, 'public', 'exchange')
|
||||||
|
const productsData = computed(() => data.value?.getProducts || [])
|
||||||
|
|
||||||
|
const selectProduct = (product: any) => {
|
||||||
|
searchStore.setProduct(product.name)
|
||||||
|
searchStore.setProductUuid(product.uuid)
|
||||||
|
const locationUuid = searchStore.searchForm.locationUuid
|
||||||
|
if (locationUuid) {
|
||||||
|
navigateTo({
|
||||||
|
path: '/request',
|
||||||
|
query: {
|
||||||
|
productUuid: product.uuid,
|
||||||
|
product: product.name,
|
||||||
|
locationUuid,
|
||||||
|
location: searchStore.searchForm.location,
|
||||||
|
quantity: searchStore.searchForm.quantity || undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/KYCFormRussia.stories.ts
Normal file
21
app/components/KYCFormRussia.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './KYCFormRussia.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'KYCFormRussia',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
242
app/components/KYCFormRussia.vue
Normal file
242
app/components/KYCFormRussia.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-0 sm:p-2">
|
||||||
|
<form @submit.prevent="submitKYC" class="space-y-6">
|
||||||
|
<!-- Company Section -->
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||||
|
<div class="card-body gap-4">
|
||||||
|
<h3 class="card-title text-base-content">Company details</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Company search with DADATA -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">
|
||||||
|
Organization search
|
||||||
|
</label>
|
||||||
|
<CompanySearchRussia v-model="formData.company" @select="onCompanySelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company details (auto-filled from DADATA) -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">INN</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.company.inn"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">KPP</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.company.kpp"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">OGRN</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.company.ogrn"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">Address</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.company.address"
|
||||||
|
class="textarea textarea-bordered w-full min-h-[120px]"
|
||||||
|
rows="3"
|
||||||
|
readonly
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bank Section -->
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||||
|
<div class="card-body gap-4">
|
||||||
|
<h3 class="card-title text-base-content">Bank details</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Bank search with DADATA -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">
|
||||||
|
Bank search
|
||||||
|
</label>
|
||||||
|
<BankSearchRussia v-model="formData.bank" @select="onBankSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bank details -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">BIC</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.bank.bik"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">Corr. account</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.bank.correspondentAccount"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Section -->
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||||
|
<div class="card-body gap-4">
|
||||||
|
<h3 class="card-title text-base-content">Contact details</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">
|
||||||
|
Contact person *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.contact.person"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Full name of company representative"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">Email *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.contact.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="email@company.ru"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">Phone *</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.contact.phone"
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="+7 (xxx) xxx-xx-xx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading || !isFormValid"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Sending...' : 'Submit for review' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [data: any]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
company: {
|
||||||
|
companyName: '',
|
||||||
|
companyFullName: '',
|
||||||
|
inn: '',
|
||||||
|
kpp: '',
|
||||||
|
ogrn: '',
|
||||||
|
address: ''
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
bankName: '',
|
||||||
|
bik: '',
|
||||||
|
correspondentAccount: ''
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
person: '',
|
||||||
|
email: '',
|
||||||
|
phone: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
const isFormValid = computed(() => {
|
||||||
|
return formData.value.company.companyName &&
|
||||||
|
formData.value.company.inn &&
|
||||||
|
formData.value.bank.bankName &&
|
||||||
|
formData.value.bank.bik &&
|
||||||
|
formData.value.contact.person &&
|
||||||
|
formData.value.contact.email &&
|
||||||
|
formData.value.contact.phone
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const onCompanySelect = (company: any) => {
|
||||||
|
formData.value.company = {
|
||||||
|
companyName: company.value,
|
||||||
|
companyFullName: company.unrestricted_value,
|
||||||
|
inn: company.data.inn,
|
||||||
|
kpp: company.data.kpp || '',
|
||||||
|
ogrn: company.data.ogrn || '',
|
||||||
|
address: company.data.address?.value || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBankSelect = (bank: any) => {
|
||||||
|
formData.value.bank = {
|
||||||
|
bankName: bank.value,
|
||||||
|
bik: bank.data.bic,
|
||||||
|
correspondentAccount: bank.data.correspondent_account || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitKYC = async () => {
|
||||||
|
if (!isFormValid.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const submitData = {
|
||||||
|
company_name: formData.value.company.companyName,
|
||||||
|
company_full_name: formData.value.company.companyFullName,
|
||||||
|
inn: formData.value.company.inn,
|
||||||
|
kpp: formData.value.company.kpp,
|
||||||
|
ogrn: formData.value.company.ogrn,
|
||||||
|
address: formData.value.company.address,
|
||||||
|
bank_name: formData.value.bank.bankName,
|
||||||
|
bik: formData.value.bank.bik,
|
||||||
|
correspondent_account: formData.value.bank.correspondentAccount,
|
||||||
|
contact_person: formData.value.contact.person,
|
||||||
|
contact_email: formData.value.contact.email,
|
||||||
|
contact_phone: formData.value.contact.phone
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', submitData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting KYC:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/LangSwitcher.stories.ts
Normal file
21
app/components/LangSwitcher.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './LangSwitcher.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'LangSwitcher',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
41
app/components/LangSwitcher.vue
Normal file
41
app/components/LangSwitcher.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1 rounded-lg p-1" :class="containerClass">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="loc in locales"
|
||||||
|
:key="loc.code"
|
||||||
|
:to="switchLocalePath(loc.code)"
|
||||||
|
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
|
||||||
|
:class="locale === loc.code ? activeClass : inactiveClass"
|
||||||
|
>
|
||||||
|
{{ loc.code.toUpperCase() }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'light'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { locale, locales } = useI18n()
|
||||||
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
|
|
||||||
|
const containerClass = computed(() =>
|
||||||
|
props.variant === 'dark' ? 'bg-base-200' : 'bg-base-100/10'
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeClass = computed(() =>
|
||||||
|
props.variant === 'dark'
|
||||||
|
? 'bg-base-100 text-base-content shadow-sm'
|
||||||
|
: 'bg-base-100/20 text-base-content'
|
||||||
|
)
|
||||||
|
|
||||||
|
const inactiveClass = computed(() =>
|
||||||
|
props.variant === 'dark'
|
||||||
|
? 'text-base-content/60 hover:text-base-content'
|
||||||
|
: 'text-base-content/60 hover:text-base-content'
|
||||||
|
)
|
||||||
|
</script>
|
||||||
21
app/components/LocationsContent.stories.ts
Normal file
21
app/components/LocationsContent.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './LocationsContent.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'LocationsContent',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
120
app/components/LocationsContent.vue
Normal file
120
app/components/LocationsContent.vue
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<Stack gap="8">
|
||||||
|
<!-- My addresses (for authenticated users) -->
|
||||||
|
<Stack v-if="isAuthenticated && teamAddresses?.length" gap="4">
|
||||||
|
<PageHeader title="My addresses">
|
||||||
|
<template #actions>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/addresses')"
|
||||||
|
class="btn btn-sm btn-ghost gap-2"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:settings" size="16" />
|
||||||
|
Manage
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<Card
|
||||||
|
v-for="addr in teamAddresses"
|
||||||
|
:key="addr.uuid"
|
||||||
|
padding="small"
|
||||||
|
interactive
|
||||||
|
@click="selectTeamAddress(addr)"
|
||||||
|
>
|
||||||
|
<Stack gap="2">
|
||||||
|
<Stack direction="row" align="center" gap="2">
|
||||||
|
<Icon name="lucide:map-pin" size="18" class="text-primary" />
|
||||||
|
<Text size="base" weight="semibold">{{ addr.name }}</Text>
|
||||||
|
<Pill v-if="addr.isDefault" variant="outline" size="sm">Default</Pill>
|
||||||
|
</Stack>
|
||||||
|
<Text tone="muted" size="sm">{{ addr.address }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<!-- Terminals and logistics hubs -->
|
||||||
|
<Stack gap="4">
|
||||||
|
<PageHeader title="Terminals and logistics hubs" />
|
||||||
|
|
||||||
|
<div v-if="pending" class="flex items-center justify-center p-8">
|
||||||
|
<span class="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert v-else-if="error" variant="error">
|
||||||
|
<Stack gap="2">
|
||||||
|
<Heading :level="4" weight="semibold">Load error</Heading>
|
||||||
|
<Button @click="refresh()">Try again</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="!locationsData?.length"
|
||||||
|
title="No locations"
|
||||||
|
description="Logistics hubs not added yet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid v-else :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<HubCard
|
||||||
|
v-for="location in locationsData"
|
||||||
|
:key="location.uuid"
|
||||||
|
:hub="location"
|
||||||
|
selectable
|
||||||
|
@select="selectLocation(location)"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GetNodesDocument } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
const searchStore = useSearchStore()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const calculateDistance = (lat: number, lng: number) => {
|
||||||
|
const moscowLat = 55.76
|
||||||
|
const moscowLng = 37.64
|
||||||
|
const distance = Math.sqrt(Math.pow(lat - moscowLat, 2) + Math.pow(lng - moscowLng, 2)) * 111
|
||||||
|
return `${Math.round(distance)} km`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load logistics hubs
|
||||||
|
const { data: locationsDataRaw, pending, error, refresh } = await useServerQuery('locations', GetNodesDocument, {}, 'public', 'geo')
|
||||||
|
const locationsData = computed(() => {
|
||||||
|
return (locationsDataRaw.value || []).map((location: any) => ({
|
||||||
|
...location,
|
||||||
|
distance: location?.latitude && location?.longitude
|
||||||
|
? calculateDistance(location.latitude, location.longitude)
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load team addresses (if authenticated)
|
||||||
|
const teamAddresses = ref<any[]>([])
|
||||||
|
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
try {
|
||||||
|
const { GetTeamAddressesDocument } = await import('~/composables/graphql/team/teams-generated')
|
||||||
|
const { data: addressData } = await useServerQuery('locations-team-addresses', GetTeamAddressesDocument, {}, 'team', 'teams')
|
||||||
|
teamAddresses.value = addressData.value?.teamAddresses || []
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Team addresses not available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectLocation = (location: any) => {
|
||||||
|
searchStore.setLocation(location.name)
|
||||||
|
searchStore.setLocationUuid(location.uuid)
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTeamAddress = (addr: any) => {
|
||||||
|
searchStore.setLocation(addr.address)
|
||||||
|
searchStore.setLocationUuid(addr.uuid)
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
117
app/components/MapSidebar.vue
Normal file
117
app/components/MapSidebar.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="w-64 bg-base-100 h-screen flex flex-col border-r border-base-300">
|
||||||
|
<!-- Header with back button -->
|
||||||
|
<div class="p-4 border-b border-base-300">
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog')"
|
||||||
|
class="btn btn-sm btn-ghost gap-2"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:arrow-left" size="18" />
|
||||||
|
{{ t('catalogMap.actions.list_view') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div role="tablist" class="tabs tabs-bordered px-4 border-b border-base-300">
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'hubs' }"
|
||||||
|
@click="$emit('update:activeTab', 'hubs')"
|
||||||
|
>
|
||||||
|
{{ t('catalogMap.tabs.hubs') }}
|
||||||
|
<span class="badge badge-sm ml-1">{{ hubs.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'suppliers' }"
|
||||||
|
@click="$emit('update:activeTab', 'suppliers')"
|
||||||
|
>
|
||||||
|
{{ t('catalogMap.tabs.suppliers') }}
|
||||||
|
<span class="badge badge-sm ml-1">{{ suppliers.length }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
:class="{ 'tab-active': activeTab === 'offers' }"
|
||||||
|
@click="$emit('update:activeTab', 'offers')"
|
||||||
|
>
|
||||||
|
{{ t('catalogMap.tabs.offers') }}
|
||||||
|
<span class="badge badge-sm ml-1">{{ offers.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 bg-base-200">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center h-32">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hubs Tab -->
|
||||||
|
<div v-else-if="activeTab === 'hubs'" class="space-y-2">
|
||||||
|
<HubCard
|
||||||
|
v-for="hub in hubs"
|
||||||
|
:key="hub.uuid"
|
||||||
|
:hub="hub"
|
||||||
|
selectable
|
||||||
|
:is-selected="selectedItemId === hub.uuid"
|
||||||
|
@select="$emit('select', hub, 'hub')"
|
||||||
|
/>
|
||||||
|
<div v-if="hubs.length === 0" class="text-center text-base-content/50 py-8">
|
||||||
|
{{ t('catalogMap.empty.hubs') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suppliers Tab -->
|
||||||
|
<div v-else-if="activeTab === 'suppliers'" class="space-y-2">
|
||||||
|
<SupplierCard
|
||||||
|
v-for="supplier in suppliers"
|
||||||
|
:key="supplier.uuid"
|
||||||
|
:supplier="supplier"
|
||||||
|
selectable
|
||||||
|
:is-selected="selectedItemId === supplier.uuid"
|
||||||
|
@select="$emit('select', supplier, 'supplier')"
|
||||||
|
/>
|
||||||
|
<div v-if="suppliers.length === 0" class="text-center text-base-content/50 py-8">
|
||||||
|
{{ t('catalogMap.empty.suppliers') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offers Tab -->
|
||||||
|
<div v-else-if="activeTab === 'offers'" class="space-y-2">
|
||||||
|
<OfferCard
|
||||||
|
v-for="offer in offers"
|
||||||
|
:key="offer.uuid"
|
||||||
|
:offer="offer"
|
||||||
|
selectable
|
||||||
|
:is-selected="selectedItemId === offer.uuid"
|
||||||
|
@select="$emit('select', offer, 'offer')"
|
||||||
|
/>
|
||||||
|
<div v-if="offers.length === 0" class="text-center text-base-content/50 py-8">
|
||||||
|
{{ t('catalogMap.empty.offers') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
activeTab: 'hubs' | 'suppliers' | 'offers'
|
||||||
|
hubs: any[]
|
||||||
|
suppliers: any[]
|
||||||
|
offers: any[]
|
||||||
|
selectedItemId: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:activeTab': [tab: 'hubs' | 'suppliers' | 'offers']
|
||||||
|
'select': [item: any, type: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
21
app/components/MapboxGlobe.client.stories.ts
Normal file
21
app/components/MapboxGlobe.client.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './MapboxGlobe.client.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'MapboxGlobe.client',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
390
app/components/MapboxGlobe.client.vue
Normal file
390
app/components/MapboxGlobe.client.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative w-full rounded-box overflow-hidden" :style="{ height: computedHeight }">
|
||||||
|
<MapboxMap
|
||||||
|
:map-id="mapId"
|
||||||
|
style="width: 100%; height: 100%"
|
||||||
|
:options="mapOptions"
|
||||||
|
@mb-created="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
|
||||||
|
<!-- Location info popup -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 translate-y-2"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
|
leave-to-class="opacity-0 translate-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedLocation"
|
||||||
|
class="absolute bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-base-100 border border-base-300 rounded-box shadow-lg p-4 z-10"
|
||||||
|
>
|
||||||
|
<Stack gap="3">
|
||||||
|
<Stack direction="row" align="center" justify="between">
|
||||||
|
<Stack gap="1">
|
||||||
|
<Heading :level="4" weight="semibold">{{ selectedLocation.name }}</Heading>
|
||||||
|
<Text tone="muted" size="base">{{ selectedLocation.country }}</Text>
|
||||||
|
</Stack>
|
||||||
|
<button
|
||||||
|
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
|
||||||
|
@click="selectedLocation = null"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Stack>
|
||||||
|
<Button size="small" @click="flyToSelectedLocation">
|
||||||
|
Fly here
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Animation indicator -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isAnimating"
|
||||||
|
class="absolute top-4 left-1/2 -translate-x-1/2 bg-base-content/70 text-base-100 px-4 py-2 rounded-full text-sm flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Flying...
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMap } from 'mapbox-gl'
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
country?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
locations: Location[]
|
||||||
|
height?: number | string
|
||||||
|
initialCenter?: [number, number]
|
||||||
|
initialZoom?: number
|
||||||
|
selectedLocationId?: string | null
|
||||||
|
mapId?: string
|
||||||
|
}>(), {
|
||||||
|
height: 500,
|
||||||
|
initialCenter: () => [37.64, 55.76], // Moscow
|
||||||
|
initialZoom: 2,
|
||||||
|
selectedLocationId: null,
|
||||||
|
mapId: 'globe-map'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute height: if number add px, else use as provided
|
||||||
|
const computedHeight = computed(() => {
|
||||||
|
if (typeof props.height === 'number') {
|
||||||
|
return `${props.height}px`
|
||||||
|
}
|
||||||
|
return props.height
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'location-click', location: Location): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// useMapboxRef gives reactive map ref when ready
|
||||||
|
const mapRef = useMapboxRef(props.mapId)
|
||||||
|
const selectedLocation = ref<Location | null>(null)
|
||||||
|
|
||||||
|
// Promise to wait for map readiness
|
||||||
|
let mapReadyResolve: (() => void) | null = null
|
||||||
|
const mapReadyPromise = new Promise<void>((resolve) => {
|
||||||
|
mapReadyResolve = resolve
|
||||||
|
})
|
||||||
|
const isMapReady = ref(false)
|
||||||
|
|
||||||
|
const { flyThroughSpace, setupGlobeAtmosphere, isAnimating } = useMapboxFlyAnimation()
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||||
|
center: props.initialCenter,
|
||||||
|
zoom: props.initialZoom,
|
||||||
|
projection: 'globe',
|
||||||
|
pitch: 20
|
||||||
|
}))
|
||||||
|
|
||||||
|
const geoJsonData = computed(() => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: props.locations
|
||||||
|
.filter(loc => loc.latitude != null && loc.longitude != null)
|
||||||
|
.map(location => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
uuid: location.uuid,
|
||||||
|
name: location.name,
|
||||||
|
country: location.country
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [location.longitude!, location.latitude!]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMap) => {
|
||||||
|
console.log('[MapboxGlobe] onMapCreated, map.loaded():', map.loaded())
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
console.log('[MapboxGlobe] initMap called')
|
||||||
|
|
||||||
|
// Setup globe atmosphere with stars
|
||||||
|
setupGlobeAtmosphere(map)
|
||||||
|
|
||||||
|
console.log('[MapboxGlobe] Map loaded, locations:', props.locations.length)
|
||||||
|
console.log('[MapboxGlobe] GeoJSON features:', geoJsonData.value.features.length)
|
||||||
|
|
||||||
|
// Add clustered source
|
||||||
|
map.addSource('locations', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geoJsonData.value,
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 14,
|
||||||
|
clusterRadius: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cluster circles
|
||||||
|
map.addLayer({
|
||||||
|
id: 'clusters',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'locations',
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: {
|
||||||
|
'circle-color': '#10b981',
|
||||||
|
'circle-radius': [
|
||||||
|
'step',
|
||||||
|
['get', 'point_count'],
|
||||||
|
20, 10,
|
||||||
|
30, 50,
|
||||||
|
40
|
||||||
|
],
|
||||||
|
'circle-opacity': 0.8,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cluster count labels
|
||||||
|
map.addLayer({
|
||||||
|
id: 'cluster-count',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'locations',
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'point_count_abbreviated'],
|
||||||
|
'text-size': 14
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Individual points
|
||||||
|
map.addLayer({
|
||||||
|
id: 'unclustered-point',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'locations',
|
||||||
|
filter: ['!', ['has', 'point_count']],
|
||||||
|
paint: {
|
||||||
|
'circle-radius': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
|
||||||
|
14,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
'circle-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'uuid'], props.selectedLocationId || '__NONE__'],
|
||||||
|
'#f59e0b',
|
||||||
|
'#10b981'
|
||||||
|
],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on cluster to zoom in
|
||||||
|
map.on('click', 'clusters', (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
|
||||||
|
if (!features.length) return
|
||||||
|
|
||||||
|
const clusterId = features[0].properties?.cluster_id
|
||||||
|
const source = map.getSource('locations') as mapboxgl.GeoJSONSource
|
||||||
|
|
||||||
|
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||||
|
if (err) return
|
||||||
|
|
||||||
|
const geometry = features[0].geometry as GeoJSON.Point
|
||||||
|
map.easeTo({
|
||||||
|
center: geometry.coordinates as [number, number],
|
||||||
|
zoom: zoom || 4
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on individual point
|
||||||
|
map.on('click', 'unclustered-point', (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
|
||||||
|
if (!features.length) return
|
||||||
|
|
||||||
|
const featureProps = features[0].properties
|
||||||
|
const geometry = features[0].geometry as GeoJSON.Point
|
||||||
|
|
||||||
|
const location: Location = {
|
||||||
|
uuid: featureProps?.uuid,
|
||||||
|
name: featureProps?.name,
|
||||||
|
country: featureProps?.country,
|
||||||
|
longitude: geometry.coordinates[0],
|
||||||
|
latitude: geometry.coordinates[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLocation.value = location
|
||||||
|
emit('location-click', location)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cursor changes
|
||||||
|
map.on('mouseenter', 'clusters', () => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
map.on('mouseleave', 'clusters', () => {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
map.on('mouseenter', 'unclustered-point', () => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer'
|
||||||
|
})
|
||||||
|
map.on('mouseleave', 'unclustered-point', () => {
|
||||||
|
map.getCanvas().style.cursor = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If map already loaded - init immediately, otherwise wait for load
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
isMapReady.value = true
|
||||||
|
mapReadyResolve?.()
|
||||||
|
} else {
|
||||||
|
map.on('load', () => {
|
||||||
|
initMap()
|
||||||
|
isMapReady.value = true
|
||||||
|
mapReadyResolve?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flyToSelectedLocation = async () => {
|
||||||
|
if (!mapRef.value || !selectedLocation.value) return
|
||||||
|
|
||||||
|
const { longitude, latitude } = selectedLocation.value
|
||||||
|
if (longitude == null || latitude == null) return
|
||||||
|
|
||||||
|
await flyThroughSpace(mapRef.value, {
|
||||||
|
targetCenter: [longitude, latitude],
|
||||||
|
targetZoom: 8,
|
||||||
|
totalDuration: 6000
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedLocation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update source data when locations change
|
||||||
|
watch(geoJsonData, (newData) => {
|
||||||
|
if (!mapRef.value) return
|
||||||
|
|
||||||
|
const source = mapRef.value.getSource('locations') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (source) {
|
||||||
|
source.setData(newData)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Update marker styles when selectedLocationId changes
|
||||||
|
watch(() => props.selectedLocationId, (newId) => {
|
||||||
|
if (!mapRef.value || !isMapReady.value) return
|
||||||
|
|
||||||
|
const map = mapRef.value
|
||||||
|
|
||||||
|
// Check if layer exists before updating
|
||||||
|
if (!map.getLayer('unclustered-point')) return
|
||||||
|
|
||||||
|
// Update circle radius
|
||||||
|
map.setPaintProperty('unclustered-point', 'circle-radius', [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'uuid'], newId || '__NONE__'],
|
||||||
|
14,
|
||||||
|
10
|
||||||
|
])
|
||||||
|
|
||||||
|
// Update circle color
|
||||||
|
map.setPaintProperty('unclustered-point', 'circle-color', [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'uuid'], newId || '__NONE__'],
|
||||||
|
'#f59e0b',
|
||||||
|
'#10b981'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fly to a specific location (exposed for parent component)
|
||||||
|
const flyToLocation = async (location: Location) => {
|
||||||
|
console.log('[MapboxGlobe] flyToLocation called:', location.name, location.longitude, location.latitude)
|
||||||
|
console.log('[MapboxGlobe] isMapReady:', isMapReady.value, 'mapRef.value:', !!mapRef.value)
|
||||||
|
|
||||||
|
const { longitude, latitude } = location
|
||||||
|
if (longitude == null || latitude == null) {
|
||||||
|
console.log('[MapboxGlobe] ERROR: coordinates are null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for map readiness if not ready
|
||||||
|
if (!isMapReady.value) {
|
||||||
|
console.log('[MapboxGlobe] waiting for map to be ready...')
|
||||||
|
await mapReadyPromise
|
||||||
|
console.log('[MapboxGlobe] map is now ready!')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapRef.value) {
|
||||||
|
console.log('[MapboxGlobe] ERROR: mapRef is still null after waiting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedLocation.value = location
|
||||||
|
|
||||||
|
console.log('[MapboxGlobe] calling flyThroughSpace:', [longitude, latitude])
|
||||||
|
// Space fly animation settings: 5000ms, minZoom 3, targetZoom 12
|
||||||
|
await flyThroughSpace(mapRef.value, {
|
||||||
|
targetCenter: [longitude, latitude],
|
||||||
|
targetZoom: 12,
|
||||||
|
totalDuration: 5000,
|
||||||
|
minZoom: 3
|
||||||
|
})
|
||||||
|
console.log('[MapboxGlobe] flyThroughSpace completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose methods for parent component
|
||||||
|
defineExpose({
|
||||||
|
flyToLocation,
|
||||||
|
resize: () => mapRef.value?.resize(),
|
||||||
|
isMapReady,
|
||||||
|
waitForReady: () => mapReadyPromise
|
||||||
|
})
|
||||||
|
</script>
|
||||||
442
app/components/NearbyConnectionsSection.vue
Normal file
442
app/components/NearbyConnectionsSection.vue
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="4">
|
||||||
|
<div v-if="autoEdges.length === 0 && railEdges.length === 0" class="text-base-content/60">
|
||||||
|
{{ t('catalogHub.nearbyHubs.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="order-2 lg:order-1">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
style="height: 360px; width: 100%;"
|
||||||
|
class="rounded-lg border border-base-300"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-[360px] w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neighbors lists -->
|
||||||
|
<div class="order-1 lg:order-2">
|
||||||
|
<Stack gap="4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Icon name="lucide:truck" size="20" />
|
||||||
|
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRoad') }}</Heading>
|
||||||
|
</div>
|
||||||
|
<Stack v-if="autoEdges.length > 0" gap="2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="edge in autoEdges"
|
||||||
|
:key="edge.toUuid"
|
||||||
|
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:map-pin" size="16" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ edge.toName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="font-semibold text-primary">{{ edge.distanceKm }} km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
<div v-else class="text-base-content/60">
|
||||||
|
{{ t('catalogHub.nearbyHubs.empty') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Icon name="lucide:train" size="20" />
|
||||||
|
<Heading :level="3">{{ t('catalogHub.nearbyHubs.byRail') }}</Heading>
|
||||||
|
</div>
|
||||||
|
<Stack v-if="railEdges.length > 0" gap="2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="edge in railEdges"
|
||||||
|
:key="edge.toUuid"
|
||||||
|
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:map-pin" size="16" class="text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ edge.toName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="font-semibold text-emerald-600">{{ edge.distanceKm }} km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
<div v-else class="text-base-content/60">
|
||||||
|
{{ t('catalogHub.nearbyHubs.empty') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
|
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
interface CurrentHub {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteGeometry {
|
||||||
|
toUuid: string
|
||||||
|
coordinates: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
autoEdges: EdgeType[]
|
||||||
|
railEdges: EdgeType[]
|
||||||
|
hub: CurrentHub
|
||||||
|
railHub: CurrentHub
|
||||||
|
autoRouteGeometries: RouteGeometry[]
|
||||||
|
railRouteGeometries: RouteGeometry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const isMapReady = ref(false)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const mapId = computed(() => `nearby-connections-${props.hub.uuid}`)
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
const points: [number, number][] = []
|
||||||
|
|
||||||
|
if (props.hub.latitude && props.hub.longitude) {
|
||||||
|
points.push([props.hub.longitude, props.hub.latitude])
|
||||||
|
}
|
||||||
|
if (props.railHub.latitude && props.railHub.longitude) {
|
||||||
|
points.push([props.railHub.longitude, props.railHub.latitude])
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEdges = [...props.autoEdges, ...props.railEdges]
|
||||||
|
allEdges.forEach((edge) => {
|
||||||
|
if (edge.toLatitude && edge.toLongitude) {
|
||||||
|
points.push([edge.toLongitude, edge.toLatitude])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (points.length === 0) return [0, 0]
|
||||||
|
|
||||||
|
const avgLng = points.reduce((sum, coord) => sum + coord[0], 0) / points.length
|
||||||
|
const avgLat = points.reduce((sum, coord) => sum + coord[1], 0) / points.length
|
||||||
|
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapZoom = computed(() => {
|
||||||
|
const distances = [...props.autoEdges, ...props.railEdges].map(e => e.distanceKm || 0)
|
||||||
|
if (distances.length === 0) return 5
|
||||||
|
|
||||||
|
const maxDistance = Math.max(...distances)
|
||||||
|
if (maxDistance > 2000) return 2
|
||||||
|
if (maxDistance > 1000) return 3
|
||||||
|
if (maxDistance > 500) return 4
|
||||||
|
if (maxDistance > 200) return 5
|
||||||
|
return 6
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: mapZoom.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const buildRouteFeatureCollection = (routes: RouteGeometry[], transportType: 'auto' | 'rail') => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: routes.map(route => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { toUuid: route.toUuid, transportType },
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: route.coordinates
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildNeighborsFeatureCollection = (edges: EdgeType[], transportType: 'auto' | 'rail') => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: edges
|
||||||
|
.filter(e => e.toLatitude && e.toLongitude)
|
||||||
|
.map(edge => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
uuid: edge.toUuid,
|
||||||
|
name: edge.toName,
|
||||||
|
distanceKm: edge.distanceKm,
|
||||||
|
transportType
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [edge.toLongitude!, edge.toLatitude!]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateRoutesSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const autoSource = map.getSource('auto-routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (autoSource) {
|
||||||
|
autoSource.setData(buildRouteFeatureCollection(props.autoRouteGeometries, 'auto'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const railSource = map.getSource('rail-routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (railSource) {
|
||||||
|
railSource.setData(buildRouteFeatureCollection(props.railRouteGeometries, 'rail'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNeighborsSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const autoSource = map.getSource('auto-neighbors') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (autoSource) {
|
||||||
|
autoSource.setData(buildNeighborsFeatureCollection(props.autoEdges, 'auto'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const railSource = map.getSource('rail-neighbors') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (railSource) {
|
||||||
|
railSource.setData(buildNeighborsFeatureCollection(props.railEdges, 'rail'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitMapToRoutes = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const coordinates = [
|
||||||
|
...props.autoRouteGeometries.flatMap(route => route.coordinates || []),
|
||||||
|
...props.railRouteGeometries.flatMap(route => route.coordinates || [])
|
||||||
|
]
|
||||||
|
|
||||||
|
if (coordinates.length === 0) return
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord as [number, number]),
|
||||||
|
new LngLatBounds(
|
||||||
|
coordinates[0] as [number, number],
|
||||||
|
coordinates[0] as [number, number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: 40,
|
||||||
|
maxZoom: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHubSource = (map: MapboxMapType, id: string, hub: CurrentHub, color: string) => {
|
||||||
|
map.addSource(id, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: hub.name },
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [hub.longitude, hub.latitude]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: `${id}-circle`,
|
||||||
|
type: 'circle',
|
||||||
|
source: id,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 10,
|
||||||
|
'circle-color': color,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', `${id}-circle`, (e) => {
|
||||||
|
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const name = e.features![0].properties?.name
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${name}</strong>`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', `${id}-circle`, () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', `${id}-circle`, () => { map.getCanvas().style.cursor = '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('auto-routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildRouteFeatureCollection(props.autoRouteGeometries, 'auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('rail-routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildRouteFeatureCollection(props.railRouteGeometries, 'rail')
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'auto-routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'auto-routes',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': '#3b82f6',
|
||||||
|
'line-width': 4,
|
||||||
|
'line-opacity': 0.8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'rail-routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'rail-routes',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': '#10b981',
|
||||||
|
'line-width': 4,
|
||||||
|
'line-opacity': 0.8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addHubSource(map, 'hub-origin', props.hub, '#3b82f6')
|
||||||
|
|
||||||
|
if (props.railHub.uuid && props.railHub.uuid !== props.hub.uuid) {
|
||||||
|
addHubSource(map, 'rail-origin', props.railHub, '#10b981')
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addSource('auto-neighbors', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildNeighborsFeatureCollection(props.autoEdges, 'auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('rail-neighbors', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildNeighborsFeatureCollection(props.railEdges, 'rail')
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'auto-neighbors-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'auto-neighbors',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': '#ef4444',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'rail-neighbors-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'rail-neighbors',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': '#22c55e',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onNeighborsClick = (e: mapboxgl.MapLayerMouseEvent) => {
|
||||||
|
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const featureProps = e.features![0].properties
|
||||||
|
const name = featureProps?.name
|
||||||
|
const distanceKm = featureProps?.distanceKm
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${name}</strong><br/>${distanceKm} km`)
|
||||||
|
.addTo(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('click', 'auto-neighbors-circles', onNeighborsClick)
|
||||||
|
map.on('click', 'rail-neighbors-circles', onNeighborsClick)
|
||||||
|
|
||||||
|
map.on('mouseenter', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'auto-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mouseenter', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'rail-neighbors-circles', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
isMapReady.value = true
|
||||||
|
updateRoutesSource()
|
||||||
|
updateNeighborsSource()
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.autoRouteGeometries, props.railRouteGeometries],
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
updateRoutesSource()
|
||||||
|
if (isMapReady.value) {
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.autoEdges, props.railEdges],
|
||||||
|
() => updateNeighborsSource(),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
374
app/components/NearbyHubsSection.vue
Normal file
374
app/components/NearbyHubsSection.vue
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="4">
|
||||||
|
<Stack direction="row" align="center" gap="2">
|
||||||
|
<Icon :name="transportIcon" size="24" />
|
||||||
|
<Heading :level="2">{{ title }}</Heading>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<div v-if="edges.length === 0" class="text-base-content/60">
|
||||||
|
{{ t('catalogHub.nearbyHubs.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="order-2 lg:order-1">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
style="height: 300px; width: 100%;"
|
||||||
|
class="rounded-lg border border-base-300"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-[300px] w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neighbors list -->
|
||||||
|
<div class="order-1 lg:order-2">
|
||||||
|
<Stack gap="2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="edge in edges"
|
||||||
|
:key="edge.toUuid"
|
||||||
|
:to="localePath(`/catalog/hubs/${edge.toUuid}`)"
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Icon name="lucide:map-pin" size="16" class="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ edge.toName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="font-semibold text-primary">{{ edge.distanceKm }} km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
|
import type { EdgeType } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
interface CurrentHub {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteGeometry {
|
||||||
|
toUuid: string
|
||||||
|
coordinates: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
edges: EdgeType[]
|
||||||
|
currentHub: CurrentHub
|
||||||
|
routeGeometries: RouteGeometry[]
|
||||||
|
transportType: 'auto' | 'rail'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const isMapReady = ref(false)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const mapId = computed(() => `nearby-hubs-${props.currentHub.uuid}-${props.transportType}`)
|
||||||
|
|
||||||
|
const transportIcon = computed(() =>
|
||||||
|
props.transportType === 'auto' ? 'lucide:truck' : 'lucide:train'
|
||||||
|
)
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
props.transportType === 'auto'
|
||||||
|
? t('catalogHub.nearbyHubs.byRoad')
|
||||||
|
: t('catalogHub.nearbyHubs.byRail')
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometryByUuid = computed(() => {
|
||||||
|
const map = new Map<string, RouteGeometry>()
|
||||||
|
props.routeGeometries.forEach((route) => {
|
||||||
|
if (route?.toUuid) {
|
||||||
|
map.set(route.toUuid, route)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineColor = computed(() =>
|
||||||
|
props.transportType === 'auto' ? '#3b82f6' : '#10b981'
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
if (!props.currentHub.latitude || !props.currentHub.longitude) {
|
||||||
|
return [0, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const allLats = [props.currentHub.latitude, ...props.edges.map(e => e.toLatitude!).filter(Boolean)]
|
||||||
|
const allLngs = [props.currentHub.longitude, ...props.edges.map(e => e.toLongitude!).filter(Boolean)]
|
||||||
|
|
||||||
|
const avgLng = allLngs.reduce((a, b) => a + b, 0) / allLngs.length
|
||||||
|
const avgLat = allLats.reduce((a, b) => a + b, 0) / allLats.length
|
||||||
|
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapZoom = computed(() => {
|
||||||
|
if (props.edges.length === 0) return 5
|
||||||
|
|
||||||
|
const distances = props.edges.map(e => e.distanceKm || 0)
|
||||||
|
const maxDistance = Math.max(...distances)
|
||||||
|
|
||||||
|
if (maxDistance > 2000) return 2
|
||||||
|
if (maxDistance > 1000) return 3
|
||||||
|
if (maxDistance > 500) return 4
|
||||||
|
if (maxDistance > 200) return 5
|
||||||
|
return 6
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: mapZoom.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const buildRouteFeatureCollection = () => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: props.routeGeometries.map(route => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { toUuid: route.toUuid },
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: route.coordinates
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildNeighborsFeatureCollection = () => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: props.edges.filter(e => e.toLatitude && e.toLongitude).map(edge => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
uuid: edge.toUuid,
|
||||||
|
name: edge.toName,
|
||||||
|
distanceKm: edge.distanceKm
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [edge.toLongitude!, edge.toLatitude!]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateRoutesSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
const source = map.getSource('routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!source) return
|
||||||
|
source.setData(buildRouteFeatureCollection())
|
||||||
|
console.log('[NearbyHubsSection] routes source updated', {
|
||||||
|
mapId: mapId.value,
|
||||||
|
routes: props.routeGeometries.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNeighborsSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
const source = map.getSource('neighbors') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!source) return
|
||||||
|
source.setData(buildNeighborsFeatureCollection())
|
||||||
|
console.log('[NearbyHubsSection] neighbors source updated', {
|
||||||
|
mapId: mapId.value,
|
||||||
|
neighbors: props.edges.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitMapToRoutes = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const coordinates = props.routeGeometries.flatMap(route => route.coordinates || [])
|
||||||
|
if (coordinates.length === 0) return
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord as [number, number]),
|
||||||
|
new LngLatBounds(
|
||||||
|
coordinates[0] as [number, number],
|
||||||
|
coordinates[0] as [number, number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: 40,
|
||||||
|
maxZoom: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
didFitBounds.value = true
|
||||||
|
console.log('[NearbyHubsSection] fitBounds applied', {
|
||||||
|
mapId: mapId.value,
|
||||||
|
points: coordinates.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
console.log('[NearbyHubsSection] map created', { mapId: mapId.value })
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildRouteFeatureCollection()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Route lines layer
|
||||||
|
map.addLayer({
|
||||||
|
id: 'routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'routes',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': lineColor.value,
|
||||||
|
'line-width': 4,
|
||||||
|
'line-opacity': 0.8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add current hub marker source
|
||||||
|
map.addSource('current-hub', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: props.currentHub.name },
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [props.currentHub.longitude, props.currentHub.latitude]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Current hub circle
|
||||||
|
map.addLayer({
|
||||||
|
id: 'current-hub-circle',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'current-hub',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 10,
|
||||||
|
'circle-color': lineColor.value,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add neighbor markers source
|
||||||
|
map.addSource('neighbors', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: buildNeighborsFeatureCollection()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Neighbor markers
|
||||||
|
map.addLayer({
|
||||||
|
id: 'neighbors-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'neighbors',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': '#ef4444',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Popups on click
|
||||||
|
map.on('click', 'current-hub-circle', (e) => {
|
||||||
|
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const name = e.features![0].properties?.name
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${name}</strong>`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', 'neighbors-circles', (e) => {
|
||||||
|
const coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const featureProps = e.features![0].properties
|
||||||
|
const name = featureProps?.name
|
||||||
|
const distanceKm = featureProps?.distanceKm
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${name}</strong><br/>${distanceKm} km`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cursor changes
|
||||||
|
map.on('mouseenter', 'current-hub-circle', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'current-hub-circle', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mouseenter', 'neighbors-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'neighbors-circles', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
isMapReady.value = true
|
||||||
|
updateRoutesSource()
|
||||||
|
updateNeighborsSource()
|
||||||
|
fitMapToRoutes()
|
||||||
|
console.log('[NearbyHubsSection] map ready', { mapId: mapId.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.routeGeometries,
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
updateRoutesSource()
|
||||||
|
if (isMapReady.value) {
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.edges,
|
||||||
|
() => updateNeighborsSource(),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[NearbyHubsSection] mounted', {
|
||||||
|
mapId: mapId.value,
|
||||||
|
edges: props.edges.length,
|
||||||
|
routes: props.routeGeometries.length,
|
||||||
|
transportType: props.transportType
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/NovuNotificationBell.client.stories.ts
Normal file
21
app/components/NovuNotificationBell.client.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './NovuNotificationBell.client.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'NovuNotificationBell.client',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
202
app/components/NovuNotificationBell.client.vue
Normal file
202
app/components/NovuNotificationBell.client.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Bell Button -->
|
||||||
|
<button
|
||||||
|
@click="toggleDropdown"
|
||||||
|
class="btn btn-ghost btn-sm btn-square"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:bell" size="18" />
|
||||||
|
<!-- Unread Badge -->
|
||||||
|
<span
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
class="absolute -top-1 -right-1 min-w-[1.25rem] h-5 px-1 rounded-full bg-error text-error-content text-xs font-semibold flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed inset-0 bg-base-content/30 z-40"
|
||||||
|
@click="isOpen = false"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Slide-in Panel -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-300"
|
||||||
|
enter-from-class="transform translate-x-full"
|
||||||
|
enter-to-class="transform translate-x-0"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="transform translate-x-0"
|
||||||
|
leave-to-class="transform translate-x-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="fixed right-0 top-0 h-full w-full sm:w-96 lg:w-1/3 bg-base-100 border border-base-300 shadow-2xl z-50 flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">Notifications</h3>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
@click="handleMarkAllAsRead"
|
||||||
|
class="text-sm text-primary hover:text-primary/80 font-medium"
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="isOpen = false"
|
||||||
|
class="p-1 text-base-content/60 hover:text-base-content transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications List -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||||
|
<svg class="animate-spin h-6 w-6 text-base-content/50" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="notifications.length === 0" class="flex flex-col items-center justify-center py-8 px-4">
|
||||||
|
<svg class="w-12 h-12 text-base-content/30 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-base-content/60">No notifications</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Items -->
|
||||||
|
<div v-else>
|
||||||
|
<button
|
||||||
|
v-for="notification in notifications"
|
||||||
|
:key="notification.id"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 last:border-b-0 transition-colors"
|
||||||
|
:class="{ 'bg-primary/10': !notification.read }"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Unread Indicator -->
|
||||||
|
<div class="flex-shrink-0 mt-1.5">
|
||||||
|
<div
|
||||||
|
v-if="!notification.read"
|
||||||
|
class="w-2 h-2 rounded-full bg-primary"
|
||||||
|
></div>
|
||||||
|
<div v-else class="w-2 h-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-base-content line-clamp-2">
|
||||||
|
{{ notification.content || notification.payload?.body || 'New notification' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
{{ formatTime(notification.createdAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div v-if="notifications.length > 0" class="px-4 py-2 border-t border-base-300 bg-base-200">
|
||||||
|
<NuxtLink
|
||||||
|
to="/clientarea/notifications"
|
||||||
|
@click="isOpen = false"
|
||||||
|
class="text-xs text-primary hover:text-primary/80 font-medium"
|
||||||
|
>
|
||||||
|
All notifications
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
subscriberId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { init, notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNovu()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Init on mount
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.subscriberId) {
|
||||||
|
init(props.subscriberId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-init on subscriber change
|
||||||
|
watch(() => props.subscriberId, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
init(newId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
onClickOutside(dropdownRef, () => {
|
||||||
|
isOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
isOpen.value = !isOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = async () => {
|
||||||
|
await markAllAsRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: { id: string; read: boolean; cta?: { data?: { url?: string } } }) => {
|
||||||
|
if (!notification.read) {
|
||||||
|
await markAsRead(notification.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate if CTA URL present
|
||||||
|
if (notification.cta?.data?.url) {
|
||||||
|
isOpen.value = false
|
||||||
|
navigateTo(notification.cta.data.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMins = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMins / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now'
|
||||||
|
if (diffMins < 60) return `${diffMins} min ago`
|
||||||
|
if (diffHours < 24) return `${diffHours} h ago`
|
||||||
|
if (diffDays < 7) return `${diffDays} d ago`
|
||||||
|
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/OrderCalendar.stories.ts
Normal file
21
app/components/OrderCalendar.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './OrderCalendar.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'OrderCalendar',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
244
app/components/OrderCalendar.vue
Normal file
244
app/components/OrderCalendar.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-4 flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">{{ t('orderCalendar.header.title') }}</h3>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Filters -->
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
id="show-loading"
|
||||||
|
v-model="showLoading"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-base-content/80">{{ t('orderCalendar.filters.loading') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
id="show-unloading"
|
||||||
|
v-model="showUnloading"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-base-content/80">{{ t('orderCalendar.filters.unloading') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simplified calendar view -->
|
||||||
|
<div class="bg-base-200 rounded-box p-4">
|
||||||
|
<div class="grid grid-cols-7 gap-2 mb-4">
|
||||||
|
<div v-for="month in months" :key="month.name" class="text-center">
|
||||||
|
<h4 class="font-medium text-sm text-base-content/80">{{ month.name }}</h4>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="week in month.weeks"
|
||||||
|
:key="week.start"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-base-content/60">{{ formatWeek(week.start) }}</span>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<div
|
||||||
|
v-for="event in week.events"
|
||||||
|
:key="event.id"
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
:style="{ backgroundColor: event.color }"
|
||||||
|
:title="event.title"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event summary -->
|
||||||
|
<div class="border-t border-base-300 pt-4">
|
||||||
|
<h4 class="font-medium text-sm text-base-content/80 mb-3">{{ t('orderCalendar.stats.title') }}</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div v-for="period in periodStats" :key="period.name" class="card bg-base-100 border border-base-300">
|
||||||
|
<div class="card-body p-3 gap-2">
|
||||||
|
<h5 class="font-medium text-sm text-base-content">{{ period.name }}</h5>
|
||||||
|
<p class="text-xs text-base-content/70">{{ period.description }}</p>
|
||||||
|
<div class="space-y-1 text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>{{ t('orderCalendar.stats.trips') }}</span>
|
||||||
|
<span class="font-medium">{{ period.tripCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>{{ t('orderCalendar.stats.companies') }}</span>
|
||||||
|
<span class="font-medium">{{ period.companyCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>{{ t('orderCalendar.stats.weight') }}</span>
|
||||||
|
<span class="font-medium">{{ period.totalWeight }}{{ t('orderCalendar.labels.weight_unit') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showLoading = ref(true)
|
||||||
|
const showUnloading = ref(true)
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const allTrips = computed(() => {
|
||||||
|
const trips = []
|
||||||
|
props.stages.forEach(stage => {
|
||||||
|
if (stage.trips?.length) {
|
||||||
|
stage.trips.forEach(trip => {
|
||||||
|
trips.push({
|
||||||
|
...trip,
|
||||||
|
stageName: stage.name,
|
||||||
|
stageType: stage.stageType
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return trips
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarEvents = computed(() => {
|
||||||
|
const events = []
|
||||||
|
|
||||||
|
allTrips.value.forEach(trip => {
|
||||||
|
const companyColor = getCompanyColor(trip.company?.name)
|
||||||
|
|
||||||
|
if (showLoading.value && trip.plannedLoadingDate) {
|
||||||
|
events.push({
|
||||||
|
id: `loading-${trip.uuid}`,
|
||||||
|
title: `${trip.name} - ${t('orderCalendar.labels.loading')}`,
|
||||||
|
date: new Date(trip.plannedLoadingDate),
|
||||||
|
color: companyColor,
|
||||||
|
type: 'loading',
|
||||||
|
trip: trip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showUnloading.value && trip.plannedUnloadingDate) {
|
||||||
|
events.push({
|
||||||
|
id: `unloading-${trip.uuid}`,
|
||||||
|
title: `${trip.name} - ${t('orderCalendar.labels.unloading')}`,
|
||||||
|
date: new Date(trip.plannedUnloadingDate),
|
||||||
|
color: companyColor,
|
||||||
|
type: 'unloading',
|
||||||
|
trip: trip
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return events
|
||||||
|
})
|
||||||
|
|
||||||
|
const months = computed(() => {
|
||||||
|
const monthsData = []
|
||||||
|
const startDate = new Date('2024-08-01')
|
||||||
|
const endDate = new Date('2024-10-31')
|
||||||
|
|
||||||
|
const monthNames = t('orderCalendar.months').split('|')
|
||||||
|
|
||||||
|
monthNames.forEach((name, index) => {
|
||||||
|
const monthStart = new Date(2024, 7 + index, 1) // August = 7
|
||||||
|
const monthEnd = new Date(2024, 8 + index, 0)
|
||||||
|
|
||||||
|
const weeks = []
|
||||||
|
let weekStart = new Date(monthStart)
|
||||||
|
|
||||||
|
while (weekStart <= monthEnd) {
|
||||||
|
const weekEnd = new Date(weekStart)
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const weekEvents = calendarEvents.value.filter(event => {
|
||||||
|
const eventDate = event.date
|
||||||
|
return eventDate >= weekStart && eventDate <= weekEnd
|
||||||
|
})
|
||||||
|
|
||||||
|
weeks.push({
|
||||||
|
start: new Date(weekStart),
|
||||||
|
events: weekEvents.slice(0, 5) // Show max 5 events
|
||||||
|
})
|
||||||
|
|
||||||
|
weekStart.setDate(weekStart.getDate() + 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
monthsData.push({
|
||||||
|
name,
|
||||||
|
weeks
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return monthsData
|
||||||
|
})
|
||||||
|
|
||||||
|
const periodStats = computed(() => {
|
||||||
|
const stats = []
|
||||||
|
|
||||||
|
// Stats per stage
|
||||||
|
props.stages.forEach(stage => {
|
||||||
|
if (stage.trips?.length) {
|
||||||
|
const companies = new Set(stage.trips.map(trip => trip.company?.name).filter(Boolean))
|
||||||
|
const totalWeight = stage.trips.reduce((sum, trip) => sum + (trip.plannedWeight || 0), 0)
|
||||||
|
|
||||||
|
stats.push({
|
||||||
|
name: stage.name,
|
||||||
|
description: stage.stageType === 'transport' ? getTransportTypeText(stage.transportType) : t('orderCalendar.labels.service'),
|
||||||
|
tripCount: stage.trips.length,
|
||||||
|
companyCount: companies.size,
|
||||||
|
totalWeight: totalWeight
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
stats.push({
|
||||||
|
name: stage.name,
|
||||||
|
description: t('orderCalendar.labels.service_stage'),
|
||||||
|
tripCount: 0,
|
||||||
|
companyCount: stage.selectedCompany ? 1 : 0,
|
||||||
|
totalWeight: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCompanyColor = (companyName) => {
|
||||||
|
const colors = {
|
||||||
|
'Kenya Coffee Logistics Ltd': '#ef4444',
|
||||||
|
'Kenya Highland Transport': '#f97316',
|
||||||
|
'Mombasa Express Cargo': '#eab308',
|
||||||
|
'East Africa Shipping Lines': '#3b82f6',
|
||||||
|
'TruckCo': '#10b981',
|
||||||
|
'AutoBase #1': '#8b5cf6',
|
||||||
|
'RailTrans': '#ec4899'
|
||||||
|
}
|
||||||
|
return colors[companyName] || '#6b7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransportTypeText = (transportType) => {
|
||||||
|
const texts = {
|
||||||
|
auto: t('orderCalendar.transport.auto'),
|
||||||
|
rail: t('orderCalendar.transport.rail'),
|
||||||
|
sea: t('orderCalendar.transport.sea'),
|
||||||
|
air: t('orderCalendar.transport.air')
|
||||||
|
}
|
||||||
|
return texts[transportType] || transportType
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatWeek = (date) => {
|
||||||
|
return `${date.getDate()}-${date.getDate() + 6}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/OrderMap.stories.ts
Normal file
21
app/components/OrderMap.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './OrderMap.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'OrderMap',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
147
app/components/OrderMap.vue
Normal file
147
app/components/OrderMap.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="h-full flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<h3 class="text-lg font-semibold text-base-content mb-4 text-center">{{ t('orderMap.header.title') }}</h3>
|
||||||
|
|
||||||
|
<!-- Route visualization -->
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Route line -->
|
||||||
|
<div class="absolute top-1/2 left-0 right-0 h-1 bg-primary transform -translate-y-1/2 z-0"></div>
|
||||||
|
|
||||||
|
<!-- Location points -->
|
||||||
|
<div class="relative z-10 flex justify-between items-center">
|
||||||
|
<div
|
||||||
|
v-for="(point, index) in routePoints"
|
||||||
|
:key="point.id"
|
||||||
|
class="flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<!-- Point marker -->
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full border-2 border-base-100 shadow-md mb-2"
|
||||||
|
:class="getPointColor(point.type)"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Point info -->
|
||||||
|
<div class="text-center card bg-base-200 border border-base-300 p-2 max-w-32">
|
||||||
|
<p class="text-xs font-medium text-base-content">{{ point.name }}</p>
|
||||||
|
<p class="text-xs text-base-content/60">{{ point.country }}</p>
|
||||||
|
<div v-if="point.stage" class="mt-1">
|
||||||
|
<p class="text-xs text-primary">{{ point.stage.name }}</p>
|
||||||
|
<p v-if="point.stage.selectedCompany" class="text-xs text-base-content/60">
|
||||||
|
{{ point.stage.selectedCompany.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Route summary -->
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-4 text-center">
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body p-3 gap-1">
|
||||||
|
<div class="text-lg font-semibold text-primary">{{ t('orderMap.stats.distance_value', { km: getTotalDistance() }) }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">{{ t('orderMap.stats.distance_label') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-200 border border-base-300">
|
||||||
|
<div class="card-body p-3 gap-1">
|
||||||
|
<div class="text-lg font-semibold text-success">{{ getCountries() }}</div>
|
||||||
|
<div class="text-xs text-base-content/60">{{ t('orderMap.stats.countries_label') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Coordinates of route points
|
||||||
|
const locationData = {
|
||||||
|
'Kenya Coffee Farm': { country: 'Kenya', lat: -1.2921, lng: 36.8219 },
|
||||||
|
'Port of Mombasa': { country: 'Kenya', lat: -4.0435, lng: 39.6682 },
|
||||||
|
'Novorossiysk Port': { country: 'Russia', lat: 44.7233, lng: 37.7683 },
|
||||||
|
'Moscow Distribution Center': { country: 'Russia', lat: 55.7558, lng: 37.6176 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const routePoints = computed(() => {
|
||||||
|
const points = []
|
||||||
|
|
||||||
|
props.stages.forEach((stage) => {
|
||||||
|
if (stage.stageType === 'transport') {
|
||||||
|
// Add source point
|
||||||
|
const sourceData = locationData[stage.sourceLocationName]
|
||||||
|
if (sourceData && !points.find(p => p.name === stage.sourceLocationName)) {
|
||||||
|
points.push({
|
||||||
|
id: `source-${stage.uuid}`,
|
||||||
|
name: stage.sourceLocationName,
|
||||||
|
country: sourceData.country,
|
||||||
|
type: 'source',
|
||||||
|
stage: stage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add destination point
|
||||||
|
const destData = locationData[stage.destinationLocationName]
|
||||||
|
if (destData && !points.find(p => p.name === stage.destinationLocationName)) {
|
||||||
|
points.push({
|
||||||
|
id: `dest-${stage.uuid}`,
|
||||||
|
name: stage.destinationLocationName,
|
||||||
|
country: destData.country,
|
||||||
|
type: 'destination',
|
||||||
|
stage: stage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Service stage
|
||||||
|
const locationData_service = locationData[stage.locationName]
|
||||||
|
if (locationData_service && !points.find(p => p.name === stage.locationName)) {
|
||||||
|
points.push({
|
||||||
|
id: `service-${stage.uuid}`,
|
||||||
|
name: stage.locationName,
|
||||||
|
country: locationData_service.country,
|
||||||
|
type: 'service',
|
||||||
|
stage: stage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPointColor = (type) => {
|
||||||
|
const colors = {
|
||||||
|
source: 'bg-success',
|
||||||
|
destination: 'bg-error',
|
||||||
|
service: 'bg-primary'
|
||||||
|
}
|
||||||
|
return colors[type] || 'bg-base-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTotalDistance = () => {
|
||||||
|
// Approximate distance between points
|
||||||
|
return '15,000' // Kenya → Russia ~15,000 km
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCountries = () => {
|
||||||
|
const countries = new Set()
|
||||||
|
routePoints.value.forEach(point => {
|
||||||
|
countries.add(point.country)
|
||||||
|
})
|
||||||
|
return countries.size
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/OrderTimeline.stories.ts
Normal file
21
app/components/OrderTimeline.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './OrderTimeline.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'OrderTimeline',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
184
app/components/OrderTimeline.vue
Normal file
184
app/components/OrderTimeline.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-0">
|
||||||
|
<div
|
||||||
|
v-for="(stage, index) in stages"
|
||||||
|
:key="stage.uuid"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<!-- Timeline connector -->
|
||||||
|
<div
|
||||||
|
v-if="index < stages.length - 1"
|
||||||
|
class="absolute left-4 top-8 w-0.5 h-16 bg-base-300"
|
||||||
|
:class="{ 'bg-success': isStageCompleted(stage) }"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Stage content -->
|
||||||
|
<div class="flex items-start space-x-4 pb-8">
|
||||||
|
<!-- Stage icon -->
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold"
|
||||||
|
:class="getStageIconClass(stage)"
|
||||||
|
>
|
||||||
|
{{ getStageIcon(stage) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stage details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-base-content">{{ stage.name }}</h3>
|
||||||
|
<span
|
||||||
|
:class="getStageStatusClass(stage)"
|
||||||
|
class="badge badge-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{ getStageStatusText(stage) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stage info -->
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<!-- Location info -->
|
||||||
|
<div class="text-sm text-base-content/70">
|
||||||
|
<span v-if="stage.stageType === 'transport'">
|
||||||
|
<i class="fas fa-route mr-1"></i>
|
||||||
|
{{ stage.sourceLocationName }} → {{ stage.destinationLocationName }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||||
|
{{ stage.locationName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company info -->
|
||||||
|
<div v-if="stage.selectedCompany" class="text-sm text-base-content/70">
|
||||||
|
<i class="fas fa-building mr-1"></i>
|
||||||
|
{{ stage.selectedCompany.name }} ({{ stage.selectedCompany.countryCode }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transport type -->
|
||||||
|
<div v-if="stage.stageType === 'transport'" class="text-sm text-base-content/70">
|
||||||
|
<i class="fas fa-truck mr-1"></i>
|
||||||
|
{{ getTransportTypeText(stage.transportType) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trips progress -->
|
||||||
|
<div v-if="stage.trips?.length > 0" class="mt-3">
|
||||||
|
<div class="flex items-center justify-between text-sm text-base-content/70 mb-1">
|
||||||
|
<span>Trips:</span>
|
||||||
|
<span>{{ getCompletedTrips(stage) }}/{{ stage.trips.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-base-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: `${getTripProgress(stage)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company breakdown -->
|
||||||
|
<div class="mt-2 text-xs text-base-content/60">
|
||||||
|
<div v-for="companyGroup in getCompanyGroups(stage.trips)" :key="companyGroup.name">
|
||||||
|
{{ companyGroup.name }}: {{ companyGroup.completed }}/{{ companyGroup.total }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStageIcon = (stage) => {
|
||||||
|
if (stage.stageType === 'service') return 'C'
|
||||||
|
|
||||||
|
switch (stage.transportType) {
|
||||||
|
case 'auto': return 'A'
|
||||||
|
case 'sea': return 'M'
|
||||||
|
case 'rail': return 'R'
|
||||||
|
case 'air': return 'A'
|
||||||
|
default: return 'A'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStageIconClass = (stage) => {
|
||||||
|
const baseClass = 'border-2'
|
||||||
|
|
||||||
|
if (isStageCompleted(stage)) {
|
||||||
|
return `${baseClass} bg-success/10 border-success text-success`
|
||||||
|
} else if (stage.status === 'in_progress') {
|
||||||
|
return `${baseClass} bg-primary/10 border-primary text-primary`
|
||||||
|
} else {
|
||||||
|
return `${baseClass} bg-base-200 border-base-300 text-base-content/60`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStageStatusClass = (stage) => {
|
||||||
|
const classes = {
|
||||||
|
pending: 'badge-ghost',
|
||||||
|
in_progress: 'badge-info',
|
||||||
|
completed: 'badge-success',
|
||||||
|
cancelled: 'badge-error'
|
||||||
|
}
|
||||||
|
return classes[stage.status] || 'badge-ghost'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStageStatusText = (stage) => {
|
||||||
|
const texts = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
cancelled: 'Cancelled'
|
||||||
|
}
|
||||||
|
return texts[stage.status] || stage.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTransportTypeText = (transportType) => {
|
||||||
|
const texts = {
|
||||||
|
auto: 'Auto transport',
|
||||||
|
rail: 'Rail transport',
|
||||||
|
sea: 'Sea transport',
|
||||||
|
air: 'Air transport'
|
||||||
|
}
|
||||||
|
return texts[transportType] || transportType
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStageCompleted = (stage) => {
|
||||||
|
return stage.status === 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCompletedTrips = (stage) => {
|
||||||
|
if (!stage.trips?.length) return 0
|
||||||
|
return stage.trips.filter(trip => trip.status === 'completed').length
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTripProgress = (stage) => {
|
||||||
|
if (!stage.trips?.length) return 0
|
||||||
|
const completed = getCompletedTrips(stage)
|
||||||
|
return (completed / stage.trips.length) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCompanyGroups = (trips) => {
|
||||||
|
const groups = {}
|
||||||
|
|
||||||
|
trips.forEach(trip => {
|
||||||
|
const companyName = trip.company?.name || 'Unknown'
|
||||||
|
if (!groups[companyName]) {
|
||||||
|
groups[companyName] = { name: companyName, total: 0, completed: 0 }
|
||||||
|
}
|
||||||
|
groups[companyName].total++
|
||||||
|
if (trip.status === 'completed') {
|
||||||
|
groups[companyName].completed++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(groups)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
417
app/components/OrdersRoutesMap.client.vue
Normal file
417
app/components/OrdersRoutesMap.client.vue
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="w-full h-full">
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
style="height: 100%; width: 100%;"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { getCurrentInstance } from 'vue'
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
|
import { GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
type RouteStage = {
|
||||||
|
fromLat?: number | null
|
||||||
|
fromLon?: number | null
|
||||||
|
fromName?: string | null
|
||||||
|
toLat?: number | null
|
||||||
|
toLon?: number | null
|
||||||
|
toName?: string | null
|
||||||
|
transportType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderRoute = {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
stages: RouteStage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
routes: {
|
||||||
|
type: Array as PropType<OrderRoute[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedOrderId: {
|
||||||
|
type: String as PropType<string | null>,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select-order', uuid: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const isMapReady = ref(false)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
|
const mapId = computed(() => `orders-map-${instanceId}`)
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: '#f59e0b',
|
||||||
|
processing: '#3b82f6',
|
||||||
|
in_transit: '#06b6d4',
|
||||||
|
delivered: '#22c55e',
|
||||||
|
cancelled: '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeGeometries = ref<Map<string, [number, number][]>>(new Map())
|
||||||
|
|
||||||
|
const allCoordinates = computed(() => {
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon) {
|
||||||
|
coords.push([stage.fromLon, stage.fromLat])
|
||||||
|
}
|
||||||
|
if (stage.toLat && stage.toLon) {
|
||||||
|
coords.push([stage.toLon, stage.toLat])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return coords
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
const coords = allCoordinates.value
|
||||||
|
if (!coords.length) return [50, 50]
|
||||||
|
const avgLng = coords.reduce((sum, c) => sum + c[0], 0) / coords.length
|
||||||
|
const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: 3
|
||||||
|
}))
|
||||||
|
|
||||||
|
const normalizeCoordinates = (geometry: unknown): [number, number][] | null => {
|
||||||
|
if (!Array.isArray(geometry)) return null
|
||||||
|
const coords = geometry
|
||||||
|
.filter(point => Array.isArray(point) && typeof point[0] === 'number' && typeof point[1] === 'number')
|
||||||
|
.map(point => [point[0], point[1]] as [number, number])
|
||||||
|
return coords.length > 1 ? coords : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRouteGeometry = async (stage: RouteStage): Promise<[number, number][] | null> => {
|
||||||
|
if (!stage.fromLat || !stage.fromLon || !stage.toLat || !stage.toLon) return null
|
||||||
|
|
||||||
|
if (stage.transportType === 'auto' || stage.transportType === 'rail') {
|
||||||
|
try {
|
||||||
|
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
|
||||||
|
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
|
||||||
|
|
||||||
|
const routeData = await execute(RouteDocument, {
|
||||||
|
fromLat: stage.fromLat,
|
||||||
|
fromLon: stage.fromLon,
|
||||||
|
toLat: stage.toLat,
|
||||||
|
toLon: stage.toLon
|
||||||
|
}, 'public', 'geo')
|
||||||
|
|
||||||
|
const geometry = routeData?.[routeField]?.geometry
|
||||||
|
if (typeof geometry === 'string') {
|
||||||
|
return normalizeCoordinates(JSON.parse(geometry))
|
||||||
|
}
|
||||||
|
return normalizeCoordinates(geometry)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load route geometry:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[stage.fromLon, stage.fromLat],
|
||||||
|
[stage.toLon, stage.toLat]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadCounter = 0
|
||||||
|
const loadDetailedRoutes = async () => {
|
||||||
|
if (!process.client) return
|
||||||
|
|
||||||
|
const requestId = ++loadCounter
|
||||||
|
const newGeometries = new Map<string, [number, number][]>()
|
||||||
|
|
||||||
|
for (const order of props.routes) {
|
||||||
|
const allCoords: [number, number][] = []
|
||||||
|
|
||||||
|
for (const stage of order.stages) {
|
||||||
|
const coords = await fetchRouteGeometry(stage)
|
||||||
|
if (coords) {
|
||||||
|
allCoords.push(...coords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allCoords.length > 0) {
|
||||||
|
newGeometries.set(order.uuid, allCoords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestId !== loadCounter) return
|
||||||
|
|
||||||
|
routeGeometries.value = newGeometries
|
||||||
|
updateRoutesSource()
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
|
||||||
|
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
const coords = routeGeometries.value.get(order.uuid)
|
||||||
|
if (coords && coords.length > 1) {
|
||||||
|
const isSelected = props.selectedOrderId === order.uuid
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
orderId: order.uuid,
|
||||||
|
orderName: order.name,
|
||||||
|
status: order.status,
|
||||||
|
color: statusColors[order.status || ''] || '#6b7280',
|
||||||
|
isSelected,
|
||||||
|
lineWidth: isSelected ? 6 : 4,
|
||||||
|
opacity: isSelected ? 1 : 0.7
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: coords
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: selected on top
|
||||||
|
features.sort((a, b) => {
|
||||||
|
if (a.properties?.isSelected) return 1
|
||||||
|
if (b.properties?.isSelected) return -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features }
|
||||||
|
})
|
||||||
|
|
||||||
|
const markersFeatureCollection = computed<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
||||||
|
const points: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
const isSelected = props.selectedOrderId === order.uuid
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon) {
|
||||||
|
const key = `${order.uuid}-from-${stage.fromLon},${stage.fromLat}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
points.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
orderId: order.uuid,
|
||||||
|
orderName: order.name,
|
||||||
|
name: stage.fromName || 'Start',
|
||||||
|
color: statusColors[order.status || ''] || '#6b7280',
|
||||||
|
isSelected,
|
||||||
|
radius: isSelected ? 10 : 6
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stage.toLat && stage.toLon) {
|
||||||
|
const key = `${order.uuid}-to-${stage.toLon},${stage.toLat}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
points.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
orderId: order.uuid,
|
||||||
|
orderName: order.name,
|
||||||
|
name: stage.toName || 'End',
|
||||||
|
color: statusColors[order.status || ''] || '#6b7280',
|
||||||
|
isSelected,
|
||||||
|
radius: isSelected ? 10 : 6
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: selected on top
|
||||||
|
points.sort((a, b) => {
|
||||||
|
if (a.properties?.isSelected) return 1
|
||||||
|
if (b.properties?.isSelected) return -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features: points }
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateRoutesSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const source = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (source) source.setData(routeLineFeatures.value)
|
||||||
|
|
||||||
|
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (markersSource) markersSource.setData(markersFeatureCollection.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitMapToRoutes = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const coords = allCoordinates.value
|
||||||
|
if (coords.length === 0) return
|
||||||
|
|
||||||
|
const bounds = coords.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord),
|
||||||
|
new LngLatBounds(coords[0], coords[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, { padding: 50, maxZoom: 8 })
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const flyToOrder = (orderId: string) => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const order = props.routes.find(o => o.uuid === orderId)
|
||||||
|
if (!order) return
|
||||||
|
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon) coords.push([stage.fromLon, stage.fromLat])
|
||||||
|
if (stage.toLat && stage.toLon) coords.push([stage.toLon, stage.toLat])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (coords.length === 0) return
|
||||||
|
|
||||||
|
if (coords.length === 1) {
|
||||||
|
map.flyTo({ center: coords[0], zoom: 8 })
|
||||||
|
} else {
|
||||||
|
const bounds = coords.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord),
|
||||||
|
new LngLatBounds(coords[0], coords[0])
|
||||||
|
)
|
||||||
|
map.fitBounds(bounds, { padding: 80, maxZoom: 8 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('orders-routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: routeLineFeatures.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'orders-routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'orders-routes',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': ['get', 'lineWidth'],
|
||||||
|
'line-opacity': ['get', 'opacity']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('orders-markers', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: markersFeatureCollection.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'orders-markers-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'orders-markers',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ['get', 'radius'],
|
||||||
|
'circle-color': ['get', 'color'],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on route line
|
||||||
|
map.on('click', 'orders-routes-lines', (e) => {
|
||||||
|
const orderId = e.features?.[0]?.properties?.orderId
|
||||||
|
if (orderId) {
|
||||||
|
emit('select-order', orderId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click on marker
|
||||||
|
map.on('click', 'orders-markers-circles', (e) => {
|
||||||
|
const props = e.features?.[0]?.properties
|
||||||
|
const orderId = props?.orderId
|
||||||
|
if (orderId) {
|
||||||
|
emit('select-order', orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${props?.name || 'Point'}</strong><br/>${props?.orderName || ''}`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', 'orders-routes-lines', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'orders-routes-lines', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mouseenter', 'orders-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'orders-markers-circles', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
isMapReady.value = true
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for selection changes
|
||||||
|
watch(() => props.selectedOrderId, (newId) => {
|
||||||
|
updateRoutesSource()
|
||||||
|
if (newId) {
|
||||||
|
flyToOrder(newId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for routes changes
|
||||||
|
watch(
|
||||||
|
() => props.routes,
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
loadDetailedRoutes()
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expose flyTo method
|
||||||
|
defineExpose({
|
||||||
|
flyTo: flyToOrder
|
||||||
|
})
|
||||||
|
</script>
|
||||||
234
app/components/OrdersRoutesPreview.client.vue
Normal file
234
app/components/OrdersRoutesPreview.client.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
:style="`height: ${height}px; width: 100%;`"
|
||||||
|
class="rounded-lg"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { getCurrentInstance } from 'vue'
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds } from 'mapbox-gl'
|
||||||
|
|
||||||
|
type RouteStage = {
|
||||||
|
fromLat?: number | null
|
||||||
|
fromLon?: number | null
|
||||||
|
toLat?: number | null
|
||||||
|
toLon?: number | null
|
||||||
|
transportType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderRoute = {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
status?: string
|
||||||
|
stages: RouteStage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
routes: {
|
||||||
|
type: Array as PropType<OrderRoute[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
|
const mapId = computed(() => `orders-preview-${instanceId}`)
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: '#f59e0b',
|
||||||
|
processing: '#3b82f6',
|
||||||
|
in_transit: '#06b6d4',
|
||||||
|
delivered: '#22c55e',
|
||||||
|
cancelled: '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCoordinates = computed(() => {
|
||||||
|
const coords: [number, number][] = []
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon) {
|
||||||
|
coords.push([stage.fromLon, stage.fromLat])
|
||||||
|
}
|
||||||
|
if (stage.toLat && stage.toLon) {
|
||||||
|
coords.push([stage.toLon, stage.toLat])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return coords
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
const coords = allCoordinates.value
|
||||||
|
if (!coords.length) return [50, 50]
|
||||||
|
const avgLng = coords.reduce((sum, c) => sum + c[0], 0) / coords.length
|
||||||
|
const avgLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: 2,
|
||||||
|
interactive: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
const routeLineFeatures = computed<GeoJSON.FeatureCollection<GeoJSON.LineString>>(() => {
|
||||||
|
const features: GeoJSON.Feature<GeoJSON.LineString>[] = []
|
||||||
|
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon && stage.toLat && stage.toLon) {
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
orderId: order.uuid,
|
||||||
|
status: order.status,
|
||||||
|
color: statusColors[order.status || ''] || '#6b7280'
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[stage.fromLon, stage.fromLat],
|
||||||
|
[stage.toLon, stage.toLat]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features }
|
||||||
|
})
|
||||||
|
|
||||||
|
const markersFeatureCollection = computed<GeoJSON.FeatureCollection<GeoJSON.Point>>(() => {
|
||||||
|
const points: GeoJSON.Feature<GeoJSON.Point>[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
props.routes.forEach(order => {
|
||||||
|
order.stages.forEach(stage => {
|
||||||
|
if (stage.fromLat && stage.fromLon) {
|
||||||
|
const key = `${stage.fromLon},${stage.fromLat}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
points.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { color: statusColors[order.status || ''] || '#6b7280' },
|
||||||
|
geometry: { type: 'Point', coordinates: [stage.fromLon, stage.fromLat] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stage.toLat && stage.toLon) {
|
||||||
|
const key = `${stage.toLon},${stage.toLat}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
points.push({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { color: statusColors[order.status || ''] || '#6b7280' },
|
||||||
|
geometry: { type: 'Point', coordinates: [stage.toLon, stage.toLat] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { type: 'FeatureCollection', features: points }
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitMapToRoutes = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const coords = allCoordinates.value
|
||||||
|
if (coords.length === 0) return
|
||||||
|
|
||||||
|
const bounds = coords.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord),
|
||||||
|
new LngLatBounds(coords[0], coords[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, { padding: 30, maxZoom: 6 })
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('orders-routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: routeLineFeatures.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'orders-routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'orders-routes',
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': 3,
|
||||||
|
'line-opacity': 0.8
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('orders-markers', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: markersFeatureCollection.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'orders-markers-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'orders-markers',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 5,
|
||||||
|
'circle-color': ['get', 'color'],
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.routes,
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const routesSource = map.getSource('orders-routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (routesSource) routesSource.setData(routeLineFeatures.value)
|
||||||
|
|
||||||
|
const markersSource = map.getSource('orders-markers') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (markersSource) markersSource.setData(markersFeatureCollection.value)
|
||||||
|
|
||||||
|
fitMapToRoutes()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
30
app/components/PaginationLoadMore.vue
Normal file
30
app/components/PaginationLoadMore.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<Stack v-if="total > 0" gap="2" align="center" justify="center">
|
||||||
|
<Text tone="muted">
|
||||||
|
{{ t('common.pagination.showing', { shown, total }) }}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
v-if="canLoadMore"
|
||||||
|
variant="outline"
|
||||||
|
@click="$emit('load-more')"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ t('common.actions.load_more') }}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
shown: number
|
||||||
|
total: number
|
||||||
|
canLoadMore: boolean
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'load-more'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
401
app/components/RequestRoutesMap.vue
Normal file
401
app/components/RequestRoutesMap.vue
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
:style="`height: ${height}px; width: 100%;`"
|
||||||
|
class="rounded-lg border border-base-300"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-72 w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||||
|
<p class="text-base-content/60">Загрузка карты...</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { getCurrentInstance } from 'vue'
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
|
import { GetAutoRouteDocument, GetRailRouteDocument } from '~/composables/graphql/public/geo-generated'
|
||||||
|
|
||||||
|
type RouteStage = {
|
||||||
|
fromLat?: number | null
|
||||||
|
fromLon?: number | null
|
||||||
|
fromName?: string | null
|
||||||
|
toLat?: number | null
|
||||||
|
toLon?: number | null
|
||||||
|
toName?: string | null
|
||||||
|
transportType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteWithStages = {
|
||||||
|
stages?: Array<RouteStage | null> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteMarker = {
|
||||||
|
lng: number
|
||||||
|
lat: number
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageGeometry = {
|
||||||
|
routeIndex: number
|
||||||
|
stageIndex: number
|
||||||
|
transportType?: string | null
|
||||||
|
coordinates: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
routes: {
|
||||||
|
type: Array as PropType<RouteWithStages[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 360
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { execute } = useGraphQL()
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const isMapReady = ref(false)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
|
const mapId = computed(() => `request-routes-${instanceId}`)
|
||||||
|
|
||||||
|
const routeColors = [
|
||||||
|
'#2563eb',
|
||||||
|
'#0f766e',
|
||||||
|
'#7c3aed',
|
||||||
|
'#ea580c',
|
||||||
|
'#16a34a'
|
||||||
|
]
|
||||||
|
|
||||||
|
const routeMarkers = computed(() => {
|
||||||
|
const markers: RouteMarker[] = []
|
||||||
|
props.routes.forEach((route, routeIndex) => {
|
||||||
|
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
|
||||||
|
if (!stages.length) return
|
||||||
|
const first = stages[0]
|
||||||
|
const last = stages[stages.length - 1]
|
||||||
|
|
||||||
|
if (typeof first.fromLat === 'number' && typeof first.fromLon === 'number') {
|
||||||
|
markers.push({
|
||||||
|
lat: first.fromLat,
|
||||||
|
lng: first.fromLon,
|
||||||
|
name: first.fromName || 'Старт',
|
||||||
|
label: `Маршрут ${routeIndex + 1} — старт`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (typeof last.toLat === 'number' && typeof last.toLon === 'number') {
|
||||||
|
markers.push({
|
||||||
|
lat: last.toLat,
|
||||||
|
lng: last.toLon,
|
||||||
|
name: last.toName || 'Финиш',
|
||||||
|
label: `Маршрут ${routeIndex + 1} — финиш`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return markers
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
const points: [number, number][] = []
|
||||||
|
routeMarkers.value.forEach(marker => {
|
||||||
|
points.push([marker.lng, marker.lat])
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!points.length) return [0, 0]
|
||||||
|
|
||||||
|
const avgLng = points.reduce((sum, coord) => sum + coord[0], 0) / points.length
|
||||||
|
const avgLat = points.reduce((sum, coord) => sum + coord[1], 0) / points.length
|
||||||
|
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: 2.5
|
||||||
|
}))
|
||||||
|
|
||||||
|
const routeLineFeatures = ref<GeoJSON.FeatureCollection<GeoJSON.LineString>>({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const markersFeatureCollection = computed(() => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: routeMarkers.value.map(marker => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
name: marker.name,
|
||||||
|
label: marker.label
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [marker.lng, marker.lat]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const normalizeCoordinates = (geometry: unknown): [number, number][] | null => {
|
||||||
|
if (!Array.isArray(geometry)) return null
|
||||||
|
|
||||||
|
const coords = geometry
|
||||||
|
.filter(point => Array.isArray(point) && typeof point[0] === 'number' && typeof point[1] === 'number')
|
||||||
|
.map(point => [point[0], point[1]] as [number, number])
|
||||||
|
|
||||||
|
return coords.length > 1 ? coords : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStageGeometry = async (stage: RouteStage, routeIndex: number, stageIndex: number): Promise<StageGeometry | null> => {
|
||||||
|
if (
|
||||||
|
typeof stage.fromLat !== 'number' ||
|
||||||
|
typeof stage.fromLon !== 'number' ||
|
||||||
|
typeof stage.toLat !== 'number' ||
|
||||||
|
typeof stage.toLon !== 'number'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromLat = stage.fromLat
|
||||||
|
const fromLon = stage.fromLon
|
||||||
|
const toLat = stage.toLat
|
||||||
|
const toLon = stage.toLon
|
||||||
|
|
||||||
|
let coordinates: [number, number][] | null = null
|
||||||
|
|
||||||
|
if (stage.transportType === 'auto' || stage.transportType === 'rail') {
|
||||||
|
try {
|
||||||
|
const RouteDocument = stage.transportType === 'auto' ? GetAutoRouteDocument : GetRailRouteDocument
|
||||||
|
const routeField = stage.transportType === 'auto' ? 'autoRoute' : 'railRoute'
|
||||||
|
|
||||||
|
const routeData = await execute(RouteDocument, { fromLat, fromLon, toLat, toLon }, 'public', 'geo')
|
||||||
|
const geometry = routeData?.[routeField]?.geometry
|
||||||
|
|
||||||
|
if (typeof geometry === 'string') {
|
||||||
|
coordinates = normalizeCoordinates(JSON.parse(geometry))
|
||||||
|
} else {
|
||||||
|
coordinates = normalizeCoordinates(geometry)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load detailed route geometry:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coordinates) {
|
||||||
|
coordinates = [
|
||||||
|
[fromLon, fromLat],
|
||||||
|
[toLon, toLat]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeIndex,
|
||||||
|
stageIndex,
|
||||||
|
transportType: stage.transportType,
|
||||||
|
coordinates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadCounter = 0
|
||||||
|
const loadDetailedRoutes = async () => {
|
||||||
|
if (!process.client) return
|
||||||
|
|
||||||
|
const requestId = ++loadCounter
|
||||||
|
|
||||||
|
const stageTasks: Array<Promise<StageGeometry | null>> = []
|
||||||
|
props.routes.forEach((route, routeIndex) => {
|
||||||
|
const stages = (route?.stages || []).filter(Boolean) as RouteStage[]
|
||||||
|
stages.forEach((stage, stageIndex) => {
|
||||||
|
stageTasks.push(fetchStageGeometry(stage, routeIndex, stageIndex))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(stageTasks)
|
||||||
|
if (requestId !== loadCounter) return
|
||||||
|
|
||||||
|
const features = results
|
||||||
|
.filter((result): result is StageGeometry => Boolean(result))
|
||||||
|
.map((result) => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
routeIndex: result.routeIndex,
|
||||||
|
stageIndex: result.stageIndex,
|
||||||
|
transportType: result.transportType,
|
||||||
|
color: routeColors[result.routeIndex % routeColors.length]
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: result.coordinates
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
routeLineFeatures.value = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRoutesSource()
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRoutesSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const source = map.getSource('request-routes') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!source) return
|
||||||
|
|
||||||
|
source.setData(routeLineFeatures.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMarkersSource = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const source = map.getSource('request-markers') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!source) return
|
||||||
|
|
||||||
|
source.setData(markersFeatureCollection.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitMapToRoutes = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const lineCoords = routeLineFeatures.value.features.flatMap((feature) => feature.geometry.coordinates)
|
||||||
|
const markerCoords = routeMarkers.value.map(marker => [marker.lng, marker.lat] as [number, number])
|
||||||
|
const coordinates = lineCoords.length > 0 ? lineCoords : markerCoords
|
||||||
|
|
||||||
|
if (coordinates.length === 0) return
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord as [number, number]),
|
||||||
|
new LngLatBounds(
|
||||||
|
coordinates[0] as [number, number],
|
||||||
|
coordinates[0] as [number, number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: 36,
|
||||||
|
maxZoom: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('request-routes', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: routeLineFeatures.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'request-routes-lines',
|
||||||
|
type: 'line',
|
||||||
|
source: 'request-routes',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': 4,
|
||||||
|
'line-opacity': 0.85
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('request-markers', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: markersFeatureCollection.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'request-markers-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'request-markers',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': '#f97316',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', 'request-markers-circles', (e) => {
|
||||||
|
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const featureProps = e.features?.[0].properties
|
||||||
|
const title = featureProps?.name || 'Точка'
|
||||||
|
const label = featureProps?.label || ''
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${title}</strong><br/>${label}`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', 'request-markers-circles', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'request-markers-circles', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
isMapReady.value = true
|
||||||
|
updateMarkersSource()
|
||||||
|
updateRoutesSource()
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.routes,
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
loadDetailedRoutes()
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => markersFeatureCollection.value,
|
||||||
|
() => {
|
||||||
|
updateMarkersSource()
|
||||||
|
if (isMapReady.value) {
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => routeLineFeatures.value,
|
||||||
|
() => {
|
||||||
|
updateRoutesSource()
|
||||||
|
if (isMapReady.value) {
|
||||||
|
fitMapToRoutes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
21
app/components/RouteMap.stories.ts
Normal file
21
app/components/RouteMap.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './RouteMap.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'RouteMap',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
281
app/components/RouteMap.vue
Normal file
281
app/components/RouteMap.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:key="mapId"
|
||||||
|
:map-id="mapId"
|
||||||
|
:style="`height: ${height}px; width: 100%;`"
|
||||||
|
class="rounded-lg border border-base-300"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
|
||||||
|
<template #fallback>
|
||||||
|
<div class="h-96 w-full bg-base-200 rounded-lg flex items-center justify-center">
|
||||||
|
<p class="text-base-content/60">{{ t('routeMap.states.loading') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
import { LngLatBounds, Popup } from 'mapbox-gl'
|
||||||
|
import { getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const mapRef = ref<MapboxMapType | null>(null)
|
||||||
|
const didFitBounds = ref(false)
|
||||||
|
|
||||||
|
const instanceId = getCurrentInstance()?.uid || Math.floor(Math.random() * 100000)
|
||||||
|
const mapId = computed(() => `route-map-${instanceId}`)
|
||||||
|
|
||||||
|
const routePoints = computed(() => {
|
||||||
|
const points: Array<{ id: string; name: string; lat: number; lng: number; companies: any[] }> = []
|
||||||
|
|
||||||
|
props.stages.forEach((stage: any) => {
|
||||||
|
if (stage.stageType === 'transport') {
|
||||||
|
if (stage.sourceLatitude && stage.sourceLongitude) {
|
||||||
|
const existingPoint = points.find(p => p.lat === stage.sourceLatitude && p.lng === stage.sourceLongitude)
|
||||||
|
if (!existingPoint) {
|
||||||
|
points.push({
|
||||||
|
id: `source-${stage.uuid}`,
|
||||||
|
name: stage.sourceLocationName || t('routeMap.points.source'),
|
||||||
|
lat: stage.sourceLatitude,
|
||||||
|
lng: stage.sourceLongitude,
|
||||||
|
companies: getStageCompanies(stage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.destinationLatitude && stage.destinationLongitude) {
|
||||||
|
const existingPoint = points.find(p => p.lat === stage.destinationLatitude && p.lng === stage.destinationLongitude)
|
||||||
|
if (!existingPoint) {
|
||||||
|
points.push({
|
||||||
|
id: `dest-${stage.uuid}`,
|
||||||
|
name: stage.destinationLocationName || t('routeMap.points.destination'),
|
||||||
|
lat: stage.destinationLatitude,
|
||||||
|
lng: stage.destinationLongitude,
|
||||||
|
companies: getStageCompanies(stage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (stage.locationLatitude && stage.locationLongitude) {
|
||||||
|
const existingPoint = points.find(p => p.lat === stage.locationLatitude && p.lng === stage.locationLongitude)
|
||||||
|
if (!existingPoint) {
|
||||||
|
points.push({
|
||||||
|
id: `service-${stage.uuid}`,
|
||||||
|
name: stage.locationName || t('routeMap.points.service'),
|
||||||
|
lat: stage.locationLatitude,
|
||||||
|
lng: stage.locationLongitude,
|
||||||
|
companies: getStageCompanies(stage)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
if (!routePoints.value.length) return [0, 0]
|
||||||
|
|
||||||
|
const lats = routePoints.value.map(p => p.lat).filter(Boolean)
|
||||||
|
const lngs = routePoints.value.map(p => p.lng).filter(Boolean)
|
||||||
|
|
||||||
|
if (!lats.length || !lngs.length) return [0, 0]
|
||||||
|
|
||||||
|
const avgLat = lats.reduce((a, b) => a + b, 0) / lats.length
|
||||||
|
const avgLng = lngs.reduce((a, b) => a + b, 0) / lngs.length
|
||||||
|
|
||||||
|
return [avgLng, avgLat]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/streets-v12',
|
||||||
|
center: mapCenter.value,
|
||||||
|
zoom: 4
|
||||||
|
}))
|
||||||
|
|
||||||
|
const routeCoordinates = computed(() => {
|
||||||
|
return routePoints.value.map(point => [point.lng, point.lat])
|
||||||
|
})
|
||||||
|
|
||||||
|
const pointsFeatureCollection = computed(() => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: routePoints.value.map(point => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: point.id,
|
||||||
|
name: point.name,
|
||||||
|
companies: point.companies
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [point.lng, point.lat]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const lineFeatureCollection = computed(() => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: routeCoordinates.value.length > 1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: routeCoordinates.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
|
||||||
|
const updateSources = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const lineSource = map.getSource('route-line') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (lineSource) {
|
||||||
|
lineSource.setData(lineFeatureCollection.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointSource = map.getSource('route-points') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (pointSource) {
|
||||||
|
pointSource.setData(pointsFeatureCollection.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitMapToData = () => {
|
||||||
|
const map = mapRef.value
|
||||||
|
if (!map || didFitBounds.value) return
|
||||||
|
|
||||||
|
const coordinates = routeCoordinates.value
|
||||||
|
if (!coordinates.length) return
|
||||||
|
|
||||||
|
const bounds = coordinates.reduce(
|
||||||
|
(acc, coord) => acc.extend(coord as [number, number]),
|
||||||
|
new LngLatBounds(
|
||||||
|
coordinates[0] as [number, number],
|
||||||
|
coordinates[0] as [number, number]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: 40,
|
||||||
|
maxZoom: 8
|
||||||
|
})
|
||||||
|
|
||||||
|
didFitBounds.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
mapRef.value = map
|
||||||
|
|
||||||
|
const initMap = () => {
|
||||||
|
map.addSource('route-line', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: lineFeatureCollection.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'route-line-layer',
|
||||||
|
type: 'line',
|
||||||
|
source: 'route-line',
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round'
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': 'var(--color-primary, #3b82f6)',
|
||||||
|
'line-width': 4,
|
||||||
|
'line-opacity': 0.75
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource('route-points', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: pointsFeatureCollection.value
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'route-points-layer',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'route-points',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 7,
|
||||||
|
'circle-color': '#f97316',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', 'route-points-layer', (e) => {
|
||||||
|
const coordinates = (e.features?.[0].geometry as GeoJSON.Point).coordinates.slice() as [number, number]
|
||||||
|
const props = e.features?.[0].properties
|
||||||
|
const name = props?.name || t('routeMap.points.service')
|
||||||
|
|
||||||
|
new Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<strong>${name}</strong>`)
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', 'route-points-layer', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'route-points-layer', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
|
||||||
|
updateSources()
|
||||||
|
fitMapToData()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [routePoints.value, routeCoordinates.value],
|
||||||
|
() => {
|
||||||
|
didFitBounds.value = false
|
||||||
|
updateSources()
|
||||||
|
fitMapToData()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const getStageCompanies = (stage: any) => {
|
||||||
|
const companies: any[] = []
|
||||||
|
|
||||||
|
if (stage.selectedCompany) {
|
||||||
|
companies.push(stage.selectedCompany)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCompanies = new Set()
|
||||||
|
stage.trips?.forEach((trip: any) => {
|
||||||
|
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) {
|
||||||
|
uniqueCompanies.add(trip.company.uuid)
|
||||||
|
companies.push(trip.company)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return companies
|
||||||
|
}
|
||||||
|
</script>
|
||||||
39
app/components/RouteMapPanel.vue
Normal file
39
app/components/RouteMapPanel.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Heading v-if="title" :level="3" weight="semibold">{{ title }}</Heading>
|
||||||
|
<RequestRoutesMap :routes="routes" :height="height" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
|
||||||
|
type RouteStage = {
|
||||||
|
fromLat?: number | null
|
||||||
|
fromLon?: number | null
|
||||||
|
fromName?: string | null
|
||||||
|
toLat?: number | null
|
||||||
|
toLon?: number | null
|
||||||
|
toName?: string | null
|
||||||
|
transportType?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteWithStages = {
|
||||||
|
stages?: Array<RouteStage | null> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
type: Array as PropType<RouteWithStages[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 320
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
59
app/components/RouteStagesList.vue
Normal file
59
app/components/RouteStagesList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Heading v-if="title" :level="3" weight="semibold">{{ title }}</Heading>
|
||||||
|
|
||||||
|
<div v-if="stages.length" class="divide-y divide-base-200">
|
||||||
|
<div
|
||||||
|
v-for="(stage, index) in stages"
|
||||||
|
:key="stage.key || `${index}-${stage.from || ''}-${stage.to || ''}`"
|
||||||
|
class="py-3 flex flex-wrap items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium text-base-content">
|
||||||
|
<span v-if="stage.from || stage.to">
|
||||||
|
{{ stage.from || stage.label || '—' }} → {{ stage.to || stage.label || '—' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ stage.label || '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="stage.meta?.length" class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
<span v-for="item in stage.meta" :key="item">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stage.distanceKm" class="text-sm text-base-content/70 shrink-0">
|
||||||
|
<span>{{ formatDistance(stage.distanceKm) }} км</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text v-else tone="muted">{{ emptyText }}</Text>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
export type RouteStageItem = {
|
||||||
|
key?: string
|
||||||
|
from?: string | null
|
||||||
|
to?: string | null
|
||||||
|
label?: string | null
|
||||||
|
distanceKm?: number | null
|
||||||
|
durationSeconds?: number | null
|
||||||
|
meta?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ title?: string; stages?: RouteStageItem[]; emptyText?: string }>(), {
|
||||||
|
stages: () => [],
|
||||||
|
title: '',
|
||||||
|
emptyText: 'Этапы не найдены.'
|
||||||
|
})
|
||||||
|
|
||||||
|
const stages = computed(() => (props.stages || []).filter(Boolean))
|
||||||
|
|
||||||
|
const formatDistance = (km: number | null | undefined) => {
|
||||||
|
if (!km) return '0'
|
||||||
|
return Math.round(km).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
16
app/components/RouteSummaryHeader.vue
Normal file
16
app/components/RouteSummaryHeader.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Heading :level="2" weight="semibold">{{ title }}</Heading>
|
||||||
|
<div v-if="meta.length" class="text-sm text-base-content/60 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<span v-for="item in meta" :key="item">{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{ title: string; meta?: string[] }>(), {
|
||||||
|
meta: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta = computed(() => (props.meta || []).filter(Boolean))
|
||||||
|
</script>
|
||||||
395
app/components/Sidebar.vue
Normal file
395
app/components/Sidebar.vue
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="bg-base-100 h-screen flex flex-col border-r border-base-300 transition-all duration-300"
|
||||||
|
:class="collapsed ? 'w-16' : 'w-64'"
|
||||||
|
>
|
||||||
|
<!-- Logo + Collapse button -->
|
||||||
|
<div class="p-4 border-b border-base-300 flex items-center justify-between">
|
||||||
|
<NuxtLink v-if="!collapsed" :to="localePath('/')" class="text-2xl font-bold text-primary">
|
||||||
|
Optovia
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink v-else :to="localePath('/')" class="text-xl font-bold text-primary">
|
||||||
|
O
|
||||||
|
</NuxtLink>
|
||||||
|
<button
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
class="btn btn-ghost btn-xs btn-square"
|
||||||
|
:title="collapsed ? 'Expand' : 'Collapse'"
|
||||||
|
>
|
||||||
|
<Icon :name="collapsed ? 'lucide:chevron-right' : 'lucide:chevron-left'" size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 overflow-y-auto py-2">
|
||||||
|
<!-- Collapsed view: show only menu item icons -->
|
||||||
|
<template v-if="collapsed">
|
||||||
|
<ul class="menu menu-vertical menu-compact rounded-none w-full px-2 py-1">
|
||||||
|
<!-- Exchange items -->
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/')"
|
||||||
|
:class="{ active: isExactActive('/') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('nav.search')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:search" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/offers')"
|
||||||
|
:class="{ active: isActive('/catalog/offers') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('nav.offers')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:tag" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/suppliers')"
|
||||||
|
:class="{ active: isActive('/catalog/suppliers') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('nav.suppliers')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:building-2" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/hubs')"
|
||||||
|
:class="{ active: isActive('/catalog/hubs') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('nav.hubs')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:warehouse" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Logged in items -->
|
||||||
|
<template v-if="loggedIn">
|
||||||
|
<li class="my-2"><div class="divider my-0 h-px"></div></li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/orders')"
|
||||||
|
:class="{ active: isActive('/clientarea/orders') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.orders')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:package" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/addresses')"
|
||||||
|
:class="{ active: isActive('/clientarea/addresses') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.addresses')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:map-pin" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/billing')"
|
||||||
|
:class="{ active: isActive('/clientarea/billing') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.billing')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:wallet" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<template v-if="isSeller">
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/offers')"
|
||||||
|
:class="{ active: isActive('/clientarea/offers') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.offers')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:tag" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<li class="my-2"><div class="divider my-0 h-px"></div></li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/ai')"
|
||||||
|
:class="{ active: isActive('/clientarea/ai') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.ai')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:sparkles" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="my-2"><div class="divider my-0 h-px"></div></li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/profile')"
|
||||||
|
:class="{ active: isActive('/clientarea/profile') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.profile')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:user" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/team')"
|
||||||
|
:class="{ active: isActive('/clientarea/team') }"
|
||||||
|
class="tooltip tooltip-right"
|
||||||
|
:data-tip="t('cabinetNav.team')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:users" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Expanded view -->
|
||||||
|
<template v-else>
|
||||||
|
<ul class="menu rounded-none w-full gap-1 px-2 py-3">
|
||||||
|
<!-- Exchange section -->
|
||||||
|
<li>
|
||||||
|
<details :open="sections.exchange">
|
||||||
|
<summary @click.prevent="toggleSection('exchange')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
|
||||||
|
<Icon name="lucide:layers" size="16" />
|
||||||
|
<span>{{ t('sidebar.exchange') }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/')" :class="{ active: isExactActive('/') }">
|
||||||
|
<Icon name="lucide:search" size="18" />
|
||||||
|
{{ t('nav.search') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/catalog/offers')" :class="{ active: isActive('/catalog/offers') }">
|
||||||
|
<Icon name="lucide:tag" size="18" />
|
||||||
|
{{ t('nav.offers') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/catalog/suppliers')" :class="{ active: isActive('/catalog/suppliers') }">
|
||||||
|
<Icon name="lucide:building-2" size="18" />
|
||||||
|
{{ t('nav.suppliers') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/catalog/hubs')" :class="{ active: isActive('/catalog/hubs') }">
|
||||||
|
<Icon name="lucide:warehouse" size="18" />
|
||||||
|
{{ t('nav.hubs') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Cabinet section - only for logged in users -->
|
||||||
|
<template v-if="loggedIn">
|
||||||
|
<!-- Orders & Logistics -->
|
||||||
|
<li>
|
||||||
|
<details :open="sections.orders">
|
||||||
|
<summary @click.prevent="toggleSection('orders')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
|
||||||
|
<Icon name="lucide:truck" size="16" />
|
||||||
|
<span>{{ t('sidebar.ordersLogistics') }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/orders')" :class="{ active: isActive('/clientarea/orders') }">
|
||||||
|
<Icon name="lucide:package" size="18" />
|
||||||
|
{{ t('cabinetNav.orders') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/addresses')" :class="{ active: isActive('/clientarea/addresses') }">
|
||||||
|
<Icon name="lucide:map-pin" size="18" />
|
||||||
|
{{ t('cabinetNav.addresses') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/billing')" :class="{ active: isActive('/clientarea/billing') }">
|
||||||
|
<Icon name="lucide:wallet" size="18" />
|
||||||
|
{{ t('cabinetNav.billing') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Seller (only for sellers) -->
|
||||||
|
<li v-if="isSeller">
|
||||||
|
<details :open="sections.seller">
|
||||||
|
<summary @click.prevent="toggleSection('seller')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
|
||||||
|
<Icon name="lucide:badge-dollar-sign" size="16" />
|
||||||
|
<span>{{ t('sidebar.seller') }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/offers')" :class="{ active: isActive('/clientarea/offers') }">
|
||||||
|
<Icon name="lucide:tag" size="18" />
|
||||||
|
{{ t('cabinetNav.offers') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- AI & Tools -->
|
||||||
|
<li>
|
||||||
|
<details :open="sections.ai">
|
||||||
|
<summary @click.prevent="toggleSection('ai')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
|
||||||
|
<Icon name="lucide:sparkles" size="16" />
|
||||||
|
<span>{{ t('sidebar.aiTools') }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/ai')" :class="{ active: isActive('/clientarea/ai') }">
|
||||||
|
<Icon name="lucide:sparkles" size="18" />
|
||||||
|
{{ t('cabinetNav.ai') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<li>
|
||||||
|
<details :open="sections.settings">
|
||||||
|
<summary @click.prevent="toggleSection('settings')" class="flex items-center gap-2 text-xs uppercase tracking-wide font-semibold text-base-content/70">
|
||||||
|
<Icon name="lucide:settings" size="16" />
|
||||||
|
<span>{{ t('sidebar.settings') }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/profile')" :class="{ active: isActive('/clientarea/profile') }">
|
||||||
|
<Icon name="lucide:user" size="18" />
|
||||||
|
{{ t('cabinetNav.profile') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/team')" :class="{ active: isActive('/clientarea/team') }">
|
||||||
|
<Icon name="lucide:users" size="18" />
|
||||||
|
{{ t('cabinetNav.team') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Company switcher (bottom) - only for logged in users -->
|
||||||
|
<div v-if="!collapsed" class="p-3 border-t border-base-300">
|
||||||
|
<template v-if="userData?.teams?.length">
|
||||||
|
<div class="dropdown dropdown-top w-full">
|
||||||
|
<div
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
class="btn btn-ghost btn-sm w-full justify-start gap-2"
|
||||||
|
>
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary text-primary-content rounded w-8 h-8 flex items-center justify-center">
|
||||||
|
<span class="text-xs">{{ getTeamInitials(userData.activeTeam?.name) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-left truncate">
|
||||||
|
<div class="text-sm font-medium truncate">{{ userData.activeTeam?.name || 'Select company' }}</div>
|
||||||
|
</div>
|
||||||
|
<Icon name="lucide:chevrons-up-down" size="16" class="opacity-50" />
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-full p-2 shadow-lg border border-base-300 mb-2"
|
||||||
|
>
|
||||||
|
<li v-for="team in userData.teams" :key="team?.id">
|
||||||
|
<a
|
||||||
|
@click="$emit('switch-team', team)"
|
||||||
|
:class="{ active: team?.id === userData.activeTeamId }"
|
||||||
|
>
|
||||||
|
{{ team?.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/team')">
|
||||||
|
<Icon name="lucide:plus" size="16" />
|
||||||
|
{{ t('sidebar.createCompany') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="loggedIn">
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/team')"
|
||||||
|
class="btn btn-ghost btn-sm w-full justify-start gap-2"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:building-2" size="18" />
|
||||||
|
<span>{{ t('sidebar.joinCompany') }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineEmits(['switch-team'])
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
userData?: {
|
||||||
|
activeTeam?: { name?: string }
|
||||||
|
activeTeamId?: string
|
||||||
|
teams?: Array<{ id?: string; name?: string }>
|
||||||
|
} | null
|
||||||
|
isSeller?: boolean
|
||||||
|
loggedIn?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Sidebar collapse state
|
||||||
|
const collapsed = ref(false)
|
||||||
|
|
||||||
|
// Section open states
|
||||||
|
const sections = reactive({
|
||||||
|
exchange: true,
|
||||||
|
orders: true,
|
||||||
|
seller: true,
|
||||||
|
ai: true,
|
||||||
|
settings: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSection = (section: keyof typeof sections) => {
|
||||||
|
sections[section] = !sections[section]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
const current = route.path
|
||||||
|
if (current === localePath(path)) return true
|
||||||
|
return current.startsWith(localePath(path) + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExactActive = (path: string) => {
|
||||||
|
return route.path === localePath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTeamInitials = (name?: string) => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(w => w[0])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/TeamCard.stories.ts
Normal file
21
app/components/TeamCard.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './TeamCard.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'TeamCard',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
65
app/components/TeamCard.vue
Normal file
65
app/components/TeamCard.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow">
|
||||||
|
<div class="card-body gap-4">
|
||||||
|
<div class="flex justify-between items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-base-content text-lg">{{ team.name }}</h3>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">
|
||||||
|
{{ $t('teams.created') }}: {{ formatDate(team.createdAt) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members Preview -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-base-content/80">{{ $t('teams.members') }}</span>
|
||||||
|
<span class="text-sm text-base-content/60">{{ membersCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex -space-x-2">
|
||||||
|
<div
|
||||||
|
v-for="(member, index) in displayMembers"
|
||||||
|
:key="member.id"
|
||||||
|
class="w-8 h-8 rounded-full bg-primary text-primary-content border-2 border-base-100 flex items-center justify-center text-xs font-medium"
|
||||||
|
:title="member.userId"
|
||||||
|
>
|
||||||
|
{{ getInitials(member.userId) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="remainingMembers > 0"
|
||||||
|
class="w-8 h-8 rounded-full bg-base-300 text-base-content border-2 border-base-100 flex items-center justify-center text-xs font-medium"
|
||||||
|
>
|
||||||
|
+{{ remainingMembers }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
team: Team
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const membersCount = computed(() => props.team?.members?.length || 1)
|
||||||
|
const displayMembers = computed(() => (props.team?.members || []).slice(0, 3))
|
||||||
|
const remainingMembers = computed(() => Math.max(0, membersCount.value - 3))
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
if (!dateString) return ''
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString('ru-RU')
|
||||||
|
} catch (e) {
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (userId: string) => {
|
||||||
|
if (!userId) return '??'
|
||||||
|
return userId.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/TeamCreateForm.stories.ts
Normal file
21
app/components/TeamCreateForm.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './TeamCreateForm.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'TeamCreateForm',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
104
app/components/TeamCreateForm.vue
Normal file
104
app/components/TeamCreateForm.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow p-6">
|
||||||
|
<h3 class="font-semibold text-base-content mb-6">{{ $t('teams.create_team') }}</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">
|
||||||
|
{{ $t('teams.team_name') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="teamName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
:placeholder="$t('teams.team_name_placeholder')"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:class="{ 'input-error': hasError }"
|
||||||
|
/>
|
||||||
|
<p v-if="hasError" class="mt-1 text-sm text-error">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content mb-2">
|
||||||
|
{{ $t('teams.company_type.label') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="teamType"
|
||||||
|
type="radio"
|
||||||
|
value="BUYER"
|
||||||
|
class="radio radio-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-base-content">{{ $t('teams.company_type.buyer') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="teamType"
|
||||||
|
type="radio"
|
||||||
|
value="SELLER"
|
||||||
|
class="radio radio-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-base-content">{{ $t('teams.company_type.seller') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
>
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading || !teamName.trim()"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<span v-if="isLoading" class="loading loading-spinner loading-xs mr-2"></span>
|
||||||
|
<span>{{ isLoading ? $t('teams.creating') : $t('teams.create_team') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CreateTeamDocument } from '~/composables/graphql/user/teams-generated'
|
||||||
|
|
||||||
|
const emit = defineEmits(['teamCreated', 'cancel'])
|
||||||
|
|
||||||
|
const teamName = ref('')
|
||||||
|
const teamType = ref('BUYER')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const hasError = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const { mutate } = useGraphQL()
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
hasError.value = false
|
||||||
|
const result = await mutate(CreateTeamDocument, {
|
||||||
|
input: {
|
||||||
|
name: teamName.value.trim(),
|
||||||
|
teamType: teamType.value
|
||||||
|
}
|
||||||
|
}, 'user', 'teams')
|
||||||
|
emit('teamCreated', result.createTeam?.team)
|
||||||
|
teamName.value = ''
|
||||||
|
teamType.value = 'BUYER'
|
||||||
|
} catch (err: any) {
|
||||||
|
hasError.value = true
|
||||||
|
error.value = err?.message || $t('teams.errors.create_failed')
|
||||||
|
console.error('Error creating team:', err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/TimelineStages.stories.ts
Normal file
21
app/components/TimelineStages.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './TimelineStages.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'TimelineStages',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
129
app/components/TimelineStages.vue
Normal file
129
app/components/TimelineStages.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(stage, index) in stages"
|
||||||
|
:key="stage.uuid"
|
||||||
|
class="p-6 border border-base-300 rounded-lg hover:bg-base-200 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<!-- Top row: number + title + location -->
|
||||||
|
<div class="flex items-center space-x-6 mb-6">
|
||||||
|
<!-- Stage number -->
|
||||||
|
<div class="flex-shrink-0 w-12 h-12 bg-primary text-primary-content rounded-full flex items-center justify-center font-bold text-lg">
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title and location -->
|
||||||
|
<div class="flex-1 flex items-start justify-between">
|
||||||
|
<h3 class="text-xl font-semibold text-base-content">{{ stage.name }}</h3>
|
||||||
|
<div class="text-right text-sm text-base-content/60">
|
||||||
|
<div v-if="stage.stageType === 'transport'">
|
||||||
|
{{ stage.sourceLocationName }} → {{ stage.destinationLocationName }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
{{ stage.locationName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Companies row -->
|
||||||
|
<div v-if="getStageCompanies(stage).length" class="mb-4">
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<div
|
||||||
|
v-for="company in getStageCompanies(stage)"
|
||||||
|
:key="company.uuid"
|
||||||
|
class="flex flex-col items-center bg-base-100 rounded-lg p-4 shadow-sm border border-base-300 min-w-32"
|
||||||
|
>
|
||||||
|
<!-- Company logo -->
|
||||||
|
<div class="w-12 h-12 bg-gradient-to-br from-primary to-accent rounded-full flex items-center justify-center text-primary-content text-sm font-bold mb-2">
|
||||||
|
{{ getCompanyInitials(company.name) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name and rating -->
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-sm font-medium text-base-content mb-1">{{ company.name }}</p>
|
||||||
|
<div class="flex items-center justify-center space-x-1">
|
||||||
|
<span class="text-yellow-400 text-xs">★</span>
|
||||||
|
<span class="text-xs text-base-content/70">{{ getTrustRating(company) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stage stats -->
|
||||||
|
<div v-if="stage.trips?.length" class="text-left">
|
||||||
|
<div class="inline-flex items-center bg-primary/10 rounded-lg px-4 py-2">
|
||||||
|
<span class="text-sm font-medium text-primary">{{ t('timelineStages.labels.trips', { count: stage.trips.length }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
stages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const getStageIcon = (stage) => {
|
||||||
|
if (stage.stageType === 'service') return 'Service'
|
||||||
|
|
||||||
|
switch (stage.transportType) {
|
||||||
|
case 'auto': return 'Auto'
|
||||||
|
case 'sea': return 'Sea'
|
||||||
|
case 'rail': return 'Rail'
|
||||||
|
case 'air': return 'Air'
|
||||||
|
default: return 'Auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const getStageCompanies = (stage) => {
|
||||||
|
const companies = []
|
||||||
|
|
||||||
|
if (stage.selectedCompany) {
|
||||||
|
companies.push(stage.selectedCompany)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCompanies = new Set()
|
||||||
|
stage.trips?.forEach(trip => {
|
||||||
|
if (trip.company && !uniqueCompanies.has(trip.company.uuid)) {
|
||||||
|
uniqueCompanies.add(trip.company.uuid)
|
||||||
|
companies.push(trip.company)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return companies
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCompanyInitials = (name) => {
|
||||||
|
if (!name) return '??'
|
||||||
|
|
||||||
|
const words = name.split(' ')
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return (words[0][0] + words[1][0]).toUpperCase()
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrustRating = (company) => {
|
||||||
|
const ratings = {
|
||||||
|
'Kenya Coffee Logistics Ltd': 4.8,
|
||||||
|
'Kenya Highland Transport': 4.6,
|
||||||
|
'Mombasa Express Cargo': 4.4,
|
||||||
|
'East Africa Shipping Lines': 4.7,
|
||||||
|
'Truck LLC': 4.5,
|
||||||
|
'Motor Depot #1': 4.3,
|
||||||
|
'RailTrans LLC': 4.6,
|
||||||
|
'Kenya Ports Authority': 4.9
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratings[company.name] || 4.2
|
||||||
|
}
|
||||||
|
</script>
|
||||||
182
app/components/TopBar.vue
Normal file
182
app/components/TopBar.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-40 bg-base-100 border-b border-base-300">
|
||||||
|
<div class="flex items-center justify-between h-16 px-4 lg:px-6">
|
||||||
|
<!-- Left: Mobile menu button + Breadcrumbs -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
@click="$emit('toggle-sidebar')"
|
||||||
|
class="btn btn-ghost btn-square lg:hidden"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:menu" size="20" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<CabinetBreadcrumbs />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Location + Search + Actions + User -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Location selector -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/select-location')"
|
||||||
|
class="btn btn-ghost gap-1 max-w-48 truncate"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:map-pin" size="18" />
|
||||||
|
<span class="hidden sm:inline truncate">
|
||||||
|
{{ locationStore.locationName || $t('common.selectLocation') }}
|
||||||
|
</span>
|
||||||
|
<Icon name="heroicons:chevron-down" size="14" class="hidden sm:inline" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Search (desktop) -->
|
||||||
|
<div class="hidden sm:block dropdown dropdown-end">
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('search.placeholder') || 'Search...'"
|
||||||
|
v-model="searchQuery"
|
||||||
|
class="input input-bordered w-72"
|
||||||
|
@focus="showCommandPalette = true"
|
||||||
|
@blur="hideCommandPalette"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="showCommandPalette"
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-72 p-2 shadow-lg border border-base-300 mt-1"
|
||||||
|
>
|
||||||
|
<li class="menu-title"><span>Quick actions</span></li>
|
||||||
|
<li><a @click="navigateToAction('/catalog')">Find materials</a></li>
|
||||||
|
<li><a @click="navigateToAction('/clientarea/orders')">My orders</a></li>
|
||||||
|
<li><a @click="navigateToAction('/clientarea/profile')">Profile settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-square"
|
||||||
|
:title="theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark')"
|
||||||
|
@click="$emit('toggle-theme')"
|
||||||
|
>
|
||||||
|
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- AI button -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/clientarea/ai')"
|
||||||
|
class="btn btn-ghost btn-square"
|
||||||
|
:title="$t('cabinetNav.ai')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:sparkles" size="18" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<ClientOnly>
|
||||||
|
<NovuNotificationBell
|
||||||
|
v-if="novuSubscriberId"
|
||||||
|
:subscriber-id="novuSubscriberId"
|
||||||
|
/>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- User menu -->
|
||||||
|
<template v-if="sessionChecked">
|
||||||
|
<template v-if="loggedIn">
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
||||||
|
<div class="w-10 rounded-full">
|
||||||
|
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
|
||||||
|
>
|
||||||
|
{{ userInitials }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300"
|
||||||
|
>
|
||||||
|
<li class="menu-title">
|
||||||
|
<span>{{ userName }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink :to="localePath('/clientarea/profile')">
|
||||||
|
<Icon name="lucide:user" size="16" />
|
||||||
|
{{ $t('dashboard.profile') }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon name="lucide:globe" size="16" />
|
||||||
|
Language
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
<li v-for="loc in locales" :key="loc.code">
|
||||||
|
<NuxtLink
|
||||||
|
:to="switchLocalePath(loc.code)"
|
||||||
|
:class="{ active: locale === loc.code }"
|
||||||
|
>
|
||||||
|
{{ loc.code.toUpperCase() }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<div class="divider my-1"></div>
|
||||||
|
<li>
|
||||||
|
<a @click="$emit('sign-out')" class="text-error">
|
||||||
|
<Icon name="lucide:log-out" size="16" />
|
||||||
|
{{ $t('auth.logout') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button @click="$emit('sign-in')" class="btn btn-ghost">
|
||||||
|
{{ $t('auth.login') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useLocationStore } from '~/stores/location'
|
||||||
|
|
||||||
|
defineEmits(['toggle-sidebar', 'sign-out', 'sign-in', 'toggle-theme'])
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sessionChecked?: boolean
|
||||||
|
loggedIn?: boolean
|
||||||
|
novuSubscriberId?: string
|
||||||
|
userAvatarSvg?: string
|
||||||
|
userName?: string
|
||||||
|
userInitials?: string
|
||||||
|
theme?: 'default' | 'night'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { locale, locales } = useI18n()
|
||||||
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
|
const locationStore = useLocationStore()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchInput = ref(null)
|
||||||
|
const showCommandPalette = ref(false)
|
||||||
|
|
||||||
|
const hideCommandPalette = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showCommandPalette.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToAction = (path: string) => {
|
||||||
|
showCommandPalette.value = false
|
||||||
|
navigateTo(localePath(path))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/TripBadge.stories.ts
Normal file
21
app/components/TripBadge.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './TripBadge.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'TripBadge',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
129
app/components/TripBadge.vue
Normal file
129
app/components/TripBadge.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group cursor-pointer transition-all duration-200"
|
||||||
|
:class="getTripStatusClass(trip)"
|
||||||
|
@click="$emit('click', trip)"
|
||||||
|
>
|
||||||
|
<!-- Main info -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="font-medium text-xs">{{ trip.name }}</span>
|
||||||
|
<span class="text-xs opacity-75">{{ trip.plannedWeight }}t</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="text-xs opacity-75 mt-1">
|
||||||
|
{{ formatTime(trip.plannedLoadingDate) }} - {{ formatTime(trip.plannedUnloadingDate) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<div class="w-full bg-black bg-opacity-20 rounded-full h-1">
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-full h-1 transition-all duration-300"
|
||||||
|
:style="{ width: `${getTripProgress(trip)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tooltip on hover -->
|
||||||
|
<div class="hidden group-hover:block absolute z-10 bg-base-200 text-base-content p-3 rounded-lg shadow-lg min-w-64 -translate-y-full -translate-x-1/2 left-1/2 border border-base-300 pointer-events-none group-hover:pointer-events-auto">
|
||||||
|
<div class="font-semibold">{{ trip.name }}</div>
|
||||||
|
<div class="text-sm mt-1">{{ trip.company?.name }}</div>
|
||||||
|
|
||||||
|
<div class="mt-2 space-y-1 text-xs">
|
||||||
|
<div>Planned weight: {{ trip.plannedWeight }}t</div>
|
||||||
|
<div v-if="trip.weightAtLoading">Weight at loading: {{ trip.weightAtLoading }}t</div>
|
||||||
|
<div v-if="trip.weightAtUnloading">Weight at unloading: {{ trip.weightAtUnloading }}t</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 space-y-1 text-xs">
|
||||||
|
<div v-if="trip.plannedLoadingDate">
|
||||||
|
📅 Planned loading: {{ formatDateTime(trip.plannedLoadingDate) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="trip.actualLoadingDate">
|
||||||
|
✅ Actual loading: {{ formatDateTime(trip.actualLoadingDate) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="trip.plannedUnloadingDate">
|
||||||
|
📅 Planned unloading: {{ formatDateTime(trip.plannedUnloadingDate) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="trip.actualUnloadingDate">
|
||||||
|
✅ Actual unloading: {{ formatDateTime(trip.actualUnloadingDate) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tooltip arrow -->
|
||||||
|
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-base-300"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
trip: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['click'])
|
||||||
|
|
||||||
|
const getTripStatusClass = (trip) => {
|
||||||
|
const baseClass = "relative inline-flex px-3 py-2 rounded-lg text-sm min-w-20 text-center text-base-100"
|
||||||
|
|
||||||
|
// Determine status by dates
|
||||||
|
const now = new Date()
|
||||||
|
const plannedStart = trip.plannedLoadingDate ? new Date(trip.plannedLoadingDate) : null
|
||||||
|
const actualEnd = trip.actualUnloadingDate ? new Date(trip.actualUnloadingDate) : null
|
||||||
|
const plannedEnd = trip.plannedUnloadingDate ? new Date(trip.plannedUnloadingDate) : null
|
||||||
|
|
||||||
|
if (actualEnd) {
|
||||||
|
// Completed
|
||||||
|
return `${baseClass} bg-success hover:bg-success/80`
|
||||||
|
} else if (plannedStart && now >= plannedStart) {
|
||||||
|
// In progress
|
||||||
|
return `${baseClass} bg-primary hover:bg-primary/80`
|
||||||
|
} else {
|
||||||
|
// Planned
|
||||||
|
return `${baseClass} bg-base-300 text-base-content hover:bg-base-400`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTripProgress = (trip) => {
|
||||||
|
const plannedStart = trip.plannedLoadingDate ? new Date(trip.plannedLoadingDate) : null
|
||||||
|
const plannedEnd = trip.plannedUnloadingDate ? new Date(trip.plannedUnloadingDate) : null
|
||||||
|
const actualEnd = trip.actualUnloadingDate ? new Date(trip.actualUnloadingDate) : null
|
||||||
|
|
||||||
|
if (!plannedStart || !plannedEnd) return 0
|
||||||
|
|
||||||
|
if (actualEnd) return 100
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
if (now < plannedStart) return 0
|
||||||
|
if (now > plannedEnd) return 90 // Overdue but not completed
|
||||||
|
|
||||||
|
const total = plannedEnd - plannedStart
|
||||||
|
const current = now - plannedStart
|
||||||
|
|
||||||
|
return Math.min((current / total) * 100, 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (dateStr) => {
|
||||||
|
if (!dateStr) return '--:--'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr) => {
|
||||||
|
if (!dateStr) return 'Not specified'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
21
app/components/UserAvatar.stories.ts
Normal file
21
app/components/UserAvatar.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './UserAvatar.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'UserAvatar',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
123
app/components/UserAvatar.vue
Normal file
123
app/components/UserAvatar.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 rounded-full overflow-hidden border-4 border-base-300 shadow-lg"
|
||||||
|
:class="{ 'animate-pulse bg-base-200': loading }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!loading && avatarSvg"
|
||||||
|
v-html="avatarSvg"
|
||||||
|
class="w-full h-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="!loading"
|
||||||
|
class="w-full h-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-primary-content text-2xl font-bold"
|
||||||
|
>
|
||||||
|
{{ initials }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change avatar button -->
|
||||||
|
<button
|
||||||
|
@click="regenerateAvatar"
|
||||||
|
:disabled="loading"
|
||||||
|
class="absolute -bottom-1 -right-1 w-8 h-8 bg-primary hover:bg-primary/80 disabled:bg-base-300 text-primary-content rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||||
|
:title="$t('profile.regenerate_avatar')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User name -->
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-semibold text-base-content">{{ displayName }}</p>
|
||||||
|
<p class="text-sm text-base-content/60" v-if="userId">ID: {{ userId }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
userId: String,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
avatarId: String,
|
||||||
|
editable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['avatar-changed'])
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const avatarSvg = ref('')
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const displayName = computed(() => {
|
||||||
|
const first = props.firstName || ''
|
||||||
|
const last = props.lastName || ''
|
||||||
|
return `${first} ${last}`.trim() || 'User'
|
||||||
|
})
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
const first = props.firstName?.charAt(0) || ''
|
||||||
|
const last = props.lastName?.charAt(0) || ''
|
||||||
|
return (first + last).toUpperCase() || '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate avatar via DiceBear API
|
||||||
|
const generateAvatar = async (seed) => {
|
||||||
|
if (!seed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// Use DiceBear API to generate SVG avatar
|
||||||
|
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
avatarSvg.value = await response.text()
|
||||||
|
} else {
|
||||||
|
console.error('Failed to generate avatar:', response.status)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating avatar:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new random avatar ID
|
||||||
|
const regenerateAvatar = async () => {
|
||||||
|
if (!props.editable || loading.value) return
|
||||||
|
|
||||||
|
const newAvatarId = Math.random().toString(36).substring(2, 15)
|
||||||
|
|
||||||
|
// Update avatar locally first
|
||||||
|
await generateAvatar(newAvatarId)
|
||||||
|
|
||||||
|
// Notify parent about avatar change
|
||||||
|
emit('avatar-changed', newAvatarId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch avatarId changes
|
||||||
|
watch(() => props.avatarId, (newAvatarId) => {
|
||||||
|
if (newAvatarId) {
|
||||||
|
generateAvatar(newAvatarId)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// If no avatarId, generate deterministic one based on userId
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.avatarId && props.userId) {
|
||||||
|
// Build deterministic ID from userId
|
||||||
|
const fallbackSeed = props.userId
|
||||||
|
await generateAvatar(fallbackSeed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
52
app/components/catalog/AddressCard.vue
Normal file
52
app/components/catalog/AddressCard.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="linkable ? NuxtLink : 'div'"
|
||||||
|
:to="linkable ? localePath('/clientarea/addresses') : undefined"
|
||||||
|
class="block"
|
||||||
|
:class="{ 'cursor-pointer': selectable }"
|
||||||
|
@click="selectable && $emit('select')"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="sm"
|
||||||
|
:interactive="linkable || selectable"
|
||||||
|
:class="[
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Stack gap="2">
|
||||||
|
<Stack direction="row" align="center" gap="2">
|
||||||
|
<Icon name="lucide:map-pin" size="18" class="text-primary" />
|
||||||
|
<Text size="base" weight="semibold">{{ address.name }}</Text>
|
||||||
|
<Pill v-if="address.isDefault" variant="outline" size="sm">{{ t('catalogAddress.badges.default') }}</Pill>
|
||||||
|
</Stack>
|
||||||
|
<Text tone="muted" size="sm">{{ address.address }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
|
interface Address {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
address?: string | null
|
||||||
|
isDefault?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
address: Address
|
||||||
|
selectable?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const linkable = computed(() => !props.selectable && props.address.uuid)
|
||||||
|
</script>
|
||||||
29
app/components/catalog/CatalogFilters.vue
Normal file
29
app/components/catalog/CatalogFilters.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<Stack direction="row" gap="2" wrap>
|
||||||
|
<Pill
|
||||||
|
v-for="filter in filters"
|
||||||
|
:key="filter.id"
|
||||||
|
:variant="filter.id === modelValue ? 'primary' : 'outline'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="$emit('update:modelValue', filter.id)"
|
||||||
|
>
|
||||||
|
{{ filter.label }}
|
||||||
|
</Pill>
|
||||||
|
</Stack>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Filter {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
filters: Filter[]
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
80
app/components/catalog/CatalogHubsSection.vue
Normal file
80
app/components/catalog/CatalogHubsSection.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="4">
|
||||||
|
<Stack direction="row" align="center" justify="between">
|
||||||
|
<Heading :level="2">{{ t('catalogHubsSection.header.title') }}</Heading>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/hubs')"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
<span>{{ t('catalogHubsSection.actions.view_all') }}</span>
|
||||||
|
<Icon name="lucide:arrow-right" size="16" />
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="6">
|
||||||
|
<div v-for="country in hubsByCountry" :key="country.name">
|
||||||
|
<Text weight="semibold" class="mb-3">{{ country.name }}</Text>
|
||||||
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<HubCard
|
||||||
|
v-for="hub in country.hubs"
|
||||||
|
:key="hub.uuid"
|
||||||
|
:hub="hub"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack v-if="totalHubs > 0" direction="row" align="center" justify="between">
|
||||||
|
<Text tone="muted">
|
||||||
|
{{ t('common.pagination.showing', { shown: hubs.length, total: totalHubs }) }}
|
||||||
|
</Text>
|
||||||
|
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||||
|
{{ t('common.actions.load_more') }}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Hub {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
distance?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hubs: Hub[]
|
||||||
|
total?: number
|
||||||
|
canLoadMore?: boolean
|
||||||
|
onLoadMore?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const totalHubs = computed(() => props.total ?? props.hubs.length)
|
||||||
|
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||||
|
const loadMore = () => {
|
||||||
|
props.onLoadMore?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubsByCountry = computed(() => {
|
||||||
|
const grouped = new Map<string, Hub[]>()
|
||||||
|
|
||||||
|
props.hubs.forEach(hub => {
|
||||||
|
const country = hub.country || 'Other'
|
||||||
|
if (!grouped.has(country)) {
|
||||||
|
grouped.set(country, [])
|
||||||
|
}
|
||||||
|
grouped.get(country)!.push(hub)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(grouped.entries())
|
||||||
|
.map(([name, hubs]) => ({ name, hubs }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
192
app/components/catalog/CatalogMap.vue
Normal file
192
app/components/catalog/CatalogMap.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1 min-h-0 w-full h-full">
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxMap
|
||||||
|
:map-id="mapId"
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
style="width: 100%; height: 100%"
|
||||||
|
:options="mapOptions"
|
||||||
|
@load="onMapCreated"
|
||||||
|
>
|
||||||
|
<MapboxNavigationControl position="top-right" />
|
||||||
|
</MapboxMap>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as MapboxMapType } from 'mapbox-gl'
|
||||||
|
|
||||||
|
interface MapItem {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
country?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mapId: string
|
||||||
|
items: MapItem[]
|
||||||
|
pointColor?: string
|
||||||
|
initialCenter?: [number, number]
|
||||||
|
initialZoom?: number
|
||||||
|
}>(), {
|
||||||
|
pointColor: '#10b981',
|
||||||
|
initialCenter: () => [37.64, 55.76],
|
||||||
|
initialZoom: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-item': [uuid: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapRef = useMapboxRef(props.mapId)
|
||||||
|
|
||||||
|
const mapOptions = computed(() => ({
|
||||||
|
style: 'mapbox://styles/mapbox/satellite-streets-v12',
|
||||||
|
center: props.initialCenter,
|
||||||
|
zoom: props.initialZoom,
|
||||||
|
projection: 'globe',
|
||||||
|
pitch: 20
|
||||||
|
}))
|
||||||
|
|
||||||
|
const geoJsonData = computed(() => ({
|
||||||
|
type: 'FeatureCollection' as const,
|
||||||
|
features: props.items.map(item => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { uuid: item.uuid, name: item.name, country: item.country },
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [item.longitude, item.latitude] }
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const sourceId = computed(() => `${props.mapId}-points`)
|
||||||
|
|
||||||
|
const onMapCreated = (map: MapboxMapType) => {
|
||||||
|
const initMap = () => {
|
||||||
|
map.setFog({
|
||||||
|
color: 'rgb(186, 210, 235)',
|
||||||
|
'high-color': 'rgb(36, 92, 223)',
|
||||||
|
'horizon-blend': 0.02,
|
||||||
|
'space-color': 'rgb(11, 11, 25)',
|
||||||
|
'star-intensity': 0.6
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addSource(sourceId.value, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geoJsonData.value,
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 14,
|
||||||
|
clusterRadius: 50
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'clusters',
|
||||||
|
type: 'circle',
|
||||||
|
source: sourceId.value,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: {
|
||||||
|
'circle-color': props.pointColor,
|
||||||
|
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 30, 50, 40],
|
||||||
|
'circle-opacity': 0.8,
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'cluster-count',
|
||||||
|
type: 'symbol',
|
||||||
|
source: sourceId.value,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'point_count_abbreviated'],
|
||||||
|
'text-size': 14
|
||||||
|
},
|
||||||
|
paint: { 'text-color': '#ffffff' }
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'unclustered-point',
|
||||||
|
type: 'circle',
|
||||||
|
source: sourceId.value,
|
||||||
|
filter: ['!', ['has', 'point_count']],
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 12,
|
||||||
|
'circle-color': props.pointColor,
|
||||||
|
'circle-stroke-width': 3,
|
||||||
|
'circle-stroke-color': '#ffffff'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: 'point-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: sourceId.value,
|
||||||
|
filter: ['!', ['has', 'point_count']],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'name'],
|
||||||
|
'text-offset': [0, 1.5],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold']
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': '#000000',
|
||||||
|
'text-halo-width': 1.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', 'clusters', (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] })
|
||||||
|
if (!features.length) return
|
||||||
|
const clusterId = features[0].properties?.cluster_id
|
||||||
|
const source = map.getSource(sourceId.value) as mapboxgl.GeoJSONSource
|
||||||
|
source.getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||||
|
if (err) return
|
||||||
|
const geometry = features[0].geometry as GeoJSON.Point
|
||||||
|
map.easeTo({ center: geometry.coordinates as [number, number], zoom: zoom || 4 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', 'unclustered-point', (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ['unclustered-point'] })
|
||||||
|
if (!features.length) return
|
||||||
|
emit('select-item', features[0].properties?.uuid)
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('mouseenter', 'clusters', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'clusters', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
map.on('mouseenter', 'unclustered-point', () => { map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
map.on('mouseleave', 'unclustered-point', () => { map.getCanvas().style.cursor = '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.loaded()) {
|
||||||
|
initMap()
|
||||||
|
} else {
|
||||||
|
map.on('load', initMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map data when items change
|
||||||
|
watch(geoJsonData, (newData) => {
|
||||||
|
if (!mapRef.value) return
|
||||||
|
const source = mapRef.value.getSource(sourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (source) {
|
||||||
|
source.setData(newData)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Expose flyTo method for external use
|
||||||
|
const flyTo = (lat: number, lng: number, zoom = 8) => {
|
||||||
|
if (!mapRef.value) return
|
||||||
|
mapRef.value.easeTo({
|
||||||
|
center: [lng, lat],
|
||||||
|
zoom,
|
||||||
|
duration: 2000,
|
||||||
|
easing: (t) => t * (2 - t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ flyTo })
|
||||||
|
</script>
|
||||||
155
app/components/catalog/CatalogMapPanel.vue
Normal file
155
app/components/catalog/CatalogMapPanel.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<Card class="h-full flex flex-col overflow-hidden">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex border-b border-base-300 flex-shrink-0 bg-base-200/70 rounded-t-xl">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="flex-1 px-4 py-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring focus-visible:ring-primary/30"
|
||||||
|
:class="[
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-primary border-b-2 border-primary bg-primary/10'
|
||||||
|
: 'text-base-content/70 hover:text-base-content hover:bg-base-200'
|
||||||
|
]"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ t(tab.label) }}
|
||||||
|
<span class="ml-1 text-xs text-base-content/60">({{ tab.count }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-2 bg-base-100">
|
||||||
|
<!-- Hubs Tab -->
|
||||||
|
<template v-if="activeTab === 'hubs'">
|
||||||
|
<HubCard
|
||||||
|
v-for="hub in hubs"
|
||||||
|
:key="hub.uuid"
|
||||||
|
:hub="hub"
|
||||||
|
selectable
|
||||||
|
:is-selected="selectedId === hub.uuid"
|
||||||
|
@select="selectHub(hub)"
|
||||||
|
/>
|
||||||
|
<Text v-if="hubs.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||||
|
{{ t('catalogMap.empty.hubs') }}
|
||||||
|
</Text>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Offers Tab -->
|
||||||
|
<template v-if="activeTab === 'offers'">
|
||||||
|
<OfferCard
|
||||||
|
v-for="offer in offers"
|
||||||
|
:key="offer.uuid"
|
||||||
|
:offer="offer"
|
||||||
|
selectable
|
||||||
|
compact
|
||||||
|
:is-selected="selectedId === offer.uuid"
|
||||||
|
@select="selectOffer(offer)"
|
||||||
|
/>
|
||||||
|
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||||
|
{{ t('catalogMap.empty.offers') }}
|
||||||
|
</Text>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Suppliers Tab -->
|
||||||
|
<template v-if="activeTab === 'suppliers'">
|
||||||
|
<SupplierCard
|
||||||
|
v-for="supplier in suppliers"
|
||||||
|
:key="supplier.uuid"
|
||||||
|
:supplier="supplier"
|
||||||
|
selectable
|
||||||
|
:is-selected="selectedId === supplier.uuid"
|
||||||
|
@select="selectSupplier(supplier)"
|
||||||
|
/>
|
||||||
|
<Text v-if="suppliers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||||
|
{{ t('catalogMap.empty.suppliers') }}
|
||||||
|
</Text>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Hub {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
distance?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offer {
|
||||||
|
uuid?: string | null
|
||||||
|
title?: string | null
|
||||||
|
locationName?: string | null
|
||||||
|
status?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
lines?: any[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
offersCount?: number | null
|
||||||
|
isVerified?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hubs: Hub[]
|
||||||
|
offers: Offer[]
|
||||||
|
suppliers: Supplier[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'fly-to', location: { uuid: string; name: string; latitude: number; longitude: number; country?: string }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type TabId = 'hubs' | 'offers' | 'suppliers'
|
||||||
|
|
||||||
|
const activeTab = ref<TabId>('hubs')
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ id: 'hubs' as const, label: 'catalogMap.tabs.hubs', count: props.hubs.length },
|
||||||
|
{ id: 'offers' as const, label: 'catalogMap.tabs.offers', count: props.offers.length },
|
||||||
|
{ id: 'suppliers' as const, label: 'catalogMap.tabs.suppliers', count: props.suppliers.length }
|
||||||
|
])
|
||||||
|
|
||||||
|
const selectHub = (hub: Hub) => {
|
||||||
|
selectedId.value = hub.uuid || null
|
||||||
|
|
||||||
|
if (hub.latitude && hub.longitude) {
|
||||||
|
emit('fly-to', {
|
||||||
|
uuid: hub.uuid!,
|
||||||
|
name: hub.name || '',
|
||||||
|
latitude: hub.latitude,
|
||||||
|
longitude: hub.longitude,
|
||||||
|
country: hub.country || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectOffer = (offer: Offer) => {
|
||||||
|
selectedId.value = offer.uuid || null
|
||||||
|
|
||||||
|
if (offer.latitude && offer.longitude) {
|
||||||
|
emit('fly-to', {
|
||||||
|
uuid: offer.uuid!,
|
||||||
|
name: offer.title || '',
|
||||||
|
latitude: offer.latitude,
|
||||||
|
longitude: offer.longitude,
|
||||||
|
country: offer.locationName || undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectSupplier = (supplier: Supplier) => {
|
||||||
|
selectedId.value = supplier.uuid || null
|
||||||
|
// Suppliers don't have coordinates currently
|
||||||
|
}
|
||||||
|
</script>
|
||||||
59
app/components/catalog/CatalogMapSidebar.vue
Normal file
59
app/components/catalog/CatalogMapSidebar.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="w-64 bg-base-100 h-screen flex flex-col border-r border-base-300">
|
||||||
|
<div class="p-4 border-b border-base-300">
|
||||||
|
<NuxtLink :to="backLink" class="btn btn-sm btn-ghost gap-2">
|
||||||
|
<Icon name="lucide:arrow-left" size="18" />
|
||||||
|
{{ backLabel }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border-b border-base-300">
|
||||||
|
<Text weight="semibold">{{ title }}</Text>
|
||||||
|
<Text tone="muted" size="sm">{{ itemsCount }} {{ t('catalogMap.labels.items') }}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filters && filters.length > 0" class="p-4 border-b border-base-300">
|
||||||
|
<CatalogFilters
|
||||||
|
:filters="filters"
|
||||||
|
:model-value="selectedFilter"
|
||||||
|
@update:model-value="$emit('update:selectedFilter', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-2 bg-base-200">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-32">
|
||||||
|
<span class="loading loading-spinner loading-md"></span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<slot name="cards" />
|
||||||
|
<div v-if="itemsCount === 0" class="text-center text-base-content/50 py-8">
|
||||||
|
{{ emptyText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Filter {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
backLink: string
|
||||||
|
backLabel: string
|
||||||
|
itemsCount: number
|
||||||
|
loading?: boolean
|
||||||
|
filters?: Filter[]
|
||||||
|
selectedFilter?: string
|
||||||
|
emptyText?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:selectedFilter': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
69
app/components/catalog/CatalogOffersSection.vue
Normal file
69
app/components/catalog/CatalogOffersSection.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="4">
|
||||||
|
<Stack direction="row" align="center" justify="between">
|
||||||
|
<Heading :level="2">{{ t('catalogOffersSection.header.title') }}</Heading>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/offers')"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
<span>{{ t('catalogOffersSection.actions.view_all') }}</span>
|
||||||
|
<Icon name="lucide:arrow-right" size="16" />
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<OfferCard
|
||||||
|
v-for="offer in offers"
|
||||||
|
:key="offer.uuid"
|
||||||
|
:offer="offer"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Stack v-if="totalOffers > 0" direction="row" align="center" justify="between">
|
||||||
|
<Text tone="muted">
|
||||||
|
{{ t('common.pagination.showing', { shown: offers.length, total: totalOffers }) }}
|
||||||
|
</Text>
|
||||||
|
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||||
|
{{ t('common.actions.load_more') }}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack v-if="offers.length === 0" align="center" gap="2">
|
||||||
|
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface OfferLine {
|
||||||
|
uuid?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Offer {
|
||||||
|
uuid?: string | null
|
||||||
|
title?: string | null
|
||||||
|
locationName?: string | null
|
||||||
|
status?: string | null
|
||||||
|
validUntil?: string | null
|
||||||
|
lines?: (OfferLine | null)[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
offers: Offer[]
|
||||||
|
total?: number
|
||||||
|
canLoadMore?: boolean
|
||||||
|
onLoadMore?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const totalOffers = computed(() => props.total ?? props.offers.length)
|
||||||
|
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||||
|
const loadMore = () => {
|
||||||
|
props.onLoadMore?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
63
app/components/catalog/CatalogSuppliersSection.vue
Normal file
63
app/components/catalog/CatalogSuppliersSection.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<Section variant="plain" paddingY="md">
|
||||||
|
<Stack gap="4">
|
||||||
|
<Stack direction="row" align="center" justify="between">
|
||||||
|
<Heading :level="2">{{ t('catalogSuppliersSection.header.title') }}</Heading>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath('/catalog/suppliers')"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
<span>{{ t('catalogSuppliersSection.actions.view_all') }}</span>
|
||||||
|
<Icon name="lucide:arrow-right" size="16" />
|
||||||
|
</NuxtLink>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||||
|
<SupplierCard
|
||||||
|
v-for="supplier in suppliers"
|
||||||
|
:key="supplier.uuid"
|
||||||
|
:supplier="supplier"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Stack v-if="totalSuppliers > 0" direction="row" align="center" justify="between">
|
||||||
|
<Text tone="muted">
|
||||||
|
{{ t('common.pagination.showing', { shown: suppliers.length, total: totalSuppliers }) }}
|
||||||
|
</Text>
|
||||||
|
<Button v-if="canLoadMore" variant="outline" @click="loadMore">
|
||||||
|
{{ t('common.actions.load_more') }}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack v-if="suppliers.length === 0" align="center" gap="2">
|
||||||
|
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Supplier {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
offersCount?: number | null
|
||||||
|
isVerified?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
suppliers: Supplier[]
|
||||||
|
total?: number
|
||||||
|
canLoadMore?: boolean
|
||||||
|
onLoadMore?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const totalSuppliers = computed(() => props.total ?? props.suppliers.length)
|
||||||
|
const canLoadMore = computed(() => props.canLoadMore ?? false)
|
||||||
|
const loadMore = () => {
|
||||||
|
props.onLoadMore?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
app/components/catalog/HubCard.vue
Normal file
70
app/components/catalog/HubCard.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="linkable ? NuxtLink : 'div'"
|
||||||
|
:to="linkable ? localePath(`/catalog/hubs/${hub.uuid}`) : undefined"
|
||||||
|
class="block"
|
||||||
|
:class="{ 'cursor-pointer': selectable }"
|
||||||
|
@click="selectable && $emit('select')"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="small"
|
||||||
|
:interactive="linkable || selectable"
|
||||||
|
:class="[
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- Title -->
|
||||||
|
<Text size="base" weight="semibold" class="truncate">{{ hub.name }}</Text>
|
||||||
|
<!-- Country left, distance right -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Text tone="muted" size="sm">
|
||||||
|
{{ countryFlag }} {{ hub.country || t('catalogMap.labels.country_unknown') }}
|
||||||
|
</Text>
|
||||||
|
<span v-if="hub.distance" class="badge badge-neutral badge-dash text-xs">
|
||||||
|
{{ hub.distance }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
|
interface Hub {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
countryCode?: string | null
|
||||||
|
distance?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hub: Hub
|
||||||
|
selectable?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const linkable = computed(() => !props.selectable && props.hub.uuid)
|
||||||
|
|
||||||
|
// ISO code to emoji flag
|
||||||
|
const isoToEmoji = (code: string): string => {
|
||||||
|
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryFlag = computed(() => {
|
||||||
|
if (props.hub.countryCode) {
|
||||||
|
return isoToEmoji(props.hub.countryCode)
|
||||||
|
}
|
||||||
|
return '🌍'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
114
app/components/catalog/MapHero.vue
Normal file
114
app/components/catalog/MapHero.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative h-80 sm:h-96 overflow-hidden rounded-2xl border border-base-300 bg-base-200">
|
||||||
|
<!-- Map -->
|
||||||
|
<ClientOnly>
|
||||||
|
<MapboxGlobe
|
||||||
|
v-if="hasCoordinates"
|
||||||
|
:key="mapKey"
|
||||||
|
:map-id="`hero-${mapKey}`"
|
||||||
|
:locations="[mapLocation]"
|
||||||
|
height="100%"
|
||||||
|
:initial-center="mapCenter"
|
||||||
|
:initial-zoom="initialZoom"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full bg-gradient-to-br from-primary/20 via-primary/10 to-base-200" />
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 sm:p-8">
|
||||||
|
<Stack gap="3">
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="text-2xl sm:text-3xl font-bold text-white">{{ title }}</h1>
|
||||||
|
<p v-if="location?.country" class="text-white/80 flex items-center gap-2">
|
||||||
|
<span class="text-xl leading-none">{{ countryFlag }}</span>
|
||||||
|
<span>{{ location.country }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<div v-if="badges.length > 0" class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-for="(badge, index) in badges"
|
||||||
|
:key="index"
|
||||||
|
class="badge badge-dash"
|
||||||
|
:class="badgeTone(badge, index)"
|
||||||
|
>
|
||||||
|
<Icon v-if="badge.icon" :name="badge.icon" size="14" />
|
||||||
|
{{ badge.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Badge {
|
||||||
|
icon?: string
|
||||||
|
text: string
|
||||||
|
tone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
country?: string | null
|
||||||
|
countryCode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
title: string
|
||||||
|
badges?: Badge[]
|
||||||
|
location?: Location | null
|
||||||
|
initialZoom?: number
|
||||||
|
}>(), {
|
||||||
|
badges: () => [],
|
||||||
|
location: null,
|
||||||
|
initialZoom: 6
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCoordinates = computed(() =>
|
||||||
|
props.location?.latitude != null && props.location?.longitude != null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key to force map recreation when location changes
|
||||||
|
const mapKey = computed(() =>
|
||||||
|
`${props.location?.uuid}-${props.location?.latitude}-${props.location?.longitude}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const mapCenter = computed<[number, number]>(() => {
|
||||||
|
if (hasCoordinates.value) {
|
||||||
|
return [props.location!.longitude!, props.location!.latitude!]
|
||||||
|
}
|
||||||
|
return [37.64, 55.76]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapLocation = computed(() => ({
|
||||||
|
uuid: props.location?.uuid,
|
||||||
|
name: props.location?.name,
|
||||||
|
latitude: props.location?.latitude,
|
||||||
|
longitude: props.location?.longitude,
|
||||||
|
country: props.location?.country
|
||||||
|
}))
|
||||||
|
|
||||||
|
const badgeTone = (badge: Badge, index: number) => {
|
||||||
|
if (badge.tone) return `badge-${badge.tone}`
|
||||||
|
const palette = ['badge-primary', 'badge-secondary', 'badge-accent', 'badge-info', 'badge-success']
|
||||||
|
return palette[index % palette.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoToEmoji = (code: string): string => {
|
||||||
|
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryFlag = computed(() => {
|
||||||
|
if (props.location?.countryCode) {
|
||||||
|
return isoToEmoji(props.location.countryCode)
|
||||||
|
}
|
||||||
|
if (props.location?.country) {
|
||||||
|
return '🌍'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
118
app/components/catalog/OfferCard.vue
Normal file
118
app/components/catalog/OfferCard.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="linkable ? NuxtLink : 'div'"
|
||||||
|
:to="linkable ? localePath(`/catalog/offers/${offer.uuid}`) : undefined"
|
||||||
|
class="block"
|
||||||
|
:class="{ 'cursor-pointer': selectable }"
|
||||||
|
@click="selectable && $emit('select')"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="small"
|
||||||
|
:interactive="linkable || selectable"
|
||||||
|
:class="[
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- Product title -->
|
||||||
|
<Text size="base" weight="semibold" class="truncate">{{ offer.productName }}</Text>
|
||||||
|
<!-- Quantity -->
|
||||||
|
<div v-if="offer.quantity" class="flex">
|
||||||
|
<span class="badge badge-neutral badge-dash text-xs">
|
||||||
|
{{ t('catalogOfferCard.labels.quantity_with_unit', { quantity: offer.quantity, unit: displayUnit }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Price -->
|
||||||
|
<div v-if="offer.pricePerUnit" class="font-semibold text-primary text-sm">
|
||||||
|
{{ formatPrice(offer.pricePerUnit, offer.currency) }}/{{ displayUnit }}
|
||||||
|
</div>
|
||||||
|
<!-- Country below -->
|
||||||
|
<Text v-if="!compact" tone="muted" size="sm">
|
||||||
|
{{ countryFlag }} {{ offer.locationCountry || offer.locationName || t('catalogOfferCard.labels.country_unknown') }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
|
interface Offer {
|
||||||
|
uuid?: string | null
|
||||||
|
// Product
|
||||||
|
productUuid?: string | null
|
||||||
|
productName?: string | null
|
||||||
|
categoryName?: string | null
|
||||||
|
// Location
|
||||||
|
locationUuid?: string | null
|
||||||
|
locationName?: string | null
|
||||||
|
locationCountry?: string | null
|
||||||
|
locationCountryCode?: string | null
|
||||||
|
// Price
|
||||||
|
quantity?: number | string | null
|
||||||
|
unit?: string | null
|
||||||
|
pricePerUnit?: number | string | null
|
||||||
|
currency?: string | null
|
||||||
|
// Misc
|
||||||
|
status?: string | null
|
||||||
|
validUntil?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
offer: Offer
|
||||||
|
selectable?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const linkable = computed(() => !props.selectable && props.offer.uuid)
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.offer.validUntil) return ''
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('ru', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
}).format(new Date(props.offer.validUntil))
|
||||||
|
} catch {
|
||||||
|
return props.offer.validUntil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPrice = (price: number | string | null | undefined, currency: string | null | undefined) => {
|
||||||
|
if (!price) return ''
|
||||||
|
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||||
|
const curr = currency || 'USD'
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('ru', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: curr,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(num)
|
||||||
|
} catch {
|
||||||
|
return `${num} ${curr}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO code to emoji flag
|
||||||
|
const isoToEmoji = (code: string): string => {
|
||||||
|
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryFlag = computed(() => {
|
||||||
|
if (props.offer.locationCountryCode) {
|
||||||
|
return isoToEmoji(props.offer.locationCountryCode)
|
||||||
|
}
|
||||||
|
return '🌍'
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayUnit = computed(() => props.offer.unit || t('catalogOfferCard.labels.default_unit'))
|
||||||
|
</script>
|
||||||
52
app/components/catalog/ProductCard.vue
Normal file
52
app/components/catalog/ProductCard.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="linkable ? NuxtLink : 'div'"
|
||||||
|
:to="linkable ? localePath(`/catalog/products/${product.uuid}`) : undefined"
|
||||||
|
class="block"
|
||||||
|
:class="{ 'cursor-pointer': selectable }"
|
||||||
|
@click="selectable && $emit('select')"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="sm"
|
||||||
|
:interactive="linkable || selectable"
|
||||||
|
:class="[
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Stack gap="2">
|
||||||
|
<Stack gap="1">
|
||||||
|
<Text size="base" weight="semibold">{{ product.name }}</Text>
|
||||||
|
<Text tone="muted">{{ product.categoryName || t('catalogProduct.labels.category_unknown') }}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Text v-if="product.description && !compact" tone="muted" size="sm">{{ product.description }}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
uuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
categoryName?: string | null
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
product: Product
|
||||||
|
selectable?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const linkable = computed(() => !props.selectable && props.product.uuid)
|
||||||
|
</script>
|
||||||
95
app/components/catalog/SupplierCard.vue
Normal file
95
app/components/catalog/SupplierCard.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="linkable ? NuxtLink : 'div'"
|
||||||
|
:to="linkable ? localePath(`/catalog/suppliers/${supplier.teamUuid || supplier.uuid}`) : undefined"
|
||||||
|
class="block"
|
||||||
|
:class="{ 'cursor-pointer': selectable }"
|
||||||
|
@click="selectable && $emit('select')"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding="small"
|
||||||
|
:interactive="linkable || selectable"
|
||||||
|
:class="[
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div v-if="supplier.logo" class="w-12 h-12 mb-1">
|
||||||
|
<img :src="supplier.logo" :alt="supplier.name || ''" class="w-full h-full object-contain rounded">
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-12 h-12 bg-primary/10 text-primary font-bold rounded flex items-center justify-center text-lg mb-1">
|
||||||
|
{{ supplier.name?.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<!-- Title -->
|
||||||
|
<Text size="base" weight="semibold" class="truncate">{{ supplier.name }}</Text>
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span v-if="supplier.isVerified" class="badge badge-neutral badge-dash text-xs">
|
||||||
|
{{ t('catalogSupplier.badges.verified') }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-neutral badge-dash text-xs">
|
||||||
|
{{ reliabilityLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Country below -->
|
||||||
|
<Text tone="muted" size="sm">
|
||||||
|
{{ countryFlag }} {{ supplier.country || t('catalogMap.labels.country_unknown') }}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtLink } from '#components'
|
||||||
|
|
||||||
|
interface Supplier {
|
||||||
|
uuid?: string | null
|
||||||
|
teamUuid?: string | null
|
||||||
|
name?: string | null
|
||||||
|
country?: string | null
|
||||||
|
countryCode?: string | null
|
||||||
|
logo?: string | null
|
||||||
|
onTimeRate?: number | null
|
||||||
|
offersCount?: number | null
|
||||||
|
isVerified?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
supplier: Supplier
|
||||||
|
selectable?: boolean
|
||||||
|
isSelected?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const linkable = computed(() => !props.selectable && (props.supplier.teamUuid || props.supplier.uuid))
|
||||||
|
|
||||||
|
const reliabilityLabel = computed(() => {
|
||||||
|
if (props.supplier.onTimeRate !== undefined && props.supplier.onTimeRate !== null) {
|
||||||
|
return t('catalogSupplier.labels.on_time', { percent: Math.round(props.supplier.onTimeRate * 100) })
|
||||||
|
}
|
||||||
|
if (props.supplier.isVerified) {
|
||||||
|
return t('catalogSupplier.labels.trusted_partner')
|
||||||
|
}
|
||||||
|
return t('catalogSupplier.labels.on_time_default')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ISO code to emoji flag
|
||||||
|
const isoToEmoji = (code: string): string => {
|
||||||
|
return code.toUpperCase().split('').map(char => String.fromCodePoint(0x1F1E6 - 65 + char.charCodeAt(0))).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryFlag = computed(() => {
|
||||||
|
if (props.supplier.countryCode) {
|
||||||
|
return isoToEmoji(props.supplier.countryCode)
|
||||||
|
}
|
||||||
|
return '🌍'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Alert.stories.ts
Normal file
21
app/components/ui/Alert.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Alert.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Alert',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
23
app/components/ui/Alert.vue
Normal file
23
app/components/ui/Alert.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="alertClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'info', // info | error | success | warning
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const variantMap: Record<string, string> = {
|
||||||
|
info: 'alert alert-info',
|
||||||
|
error: 'alert alert-error',
|
||||||
|
success: 'alert alert-success',
|
||||||
|
warning: 'alert alert-warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertClass = computed(() => variantMap[props.variant] || variantMap.info)
|
||||||
|
</script>
|
||||||
40
app/components/ui/Badge.vue
Normal file
40
app/components/ui/Badge.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="badgeClass">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'default', // default | success | warning | error | muted | primary
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm', // xs | sm | md
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const variantMap: Record<string, string> = {
|
||||||
|
default: 'badge-neutral',
|
||||||
|
success: 'badge-success',
|
||||||
|
warning: 'badge-warning',
|
||||||
|
error: 'badge-error',
|
||||||
|
muted: 'badge-ghost',
|
||||||
|
primary: 'badge-primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap: Record<string, string> = {
|
||||||
|
xs: 'badge-xs',
|
||||||
|
sm: 'badge-sm',
|
||||||
|
md: 'badge-md',
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeClass = computed(() => {
|
||||||
|
const base = 'badge'
|
||||||
|
const variantClass = variantMap[props.variant] || variantMap.default
|
||||||
|
const sizeClass = sizeMap[props.size] || sizeMap.sm
|
||||||
|
return [base, variantClass, sizeClass].join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
46
app/components/ui/Button.stories.ts
Normal file
46
app/components/ui/Button.stories.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Button> = {
|
||||||
|
title: 'UI/Button',
|
||||||
|
component: Button,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { Button },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<Button v-bind="args">{{ args.label }}</Button>'
|
||||||
|
}),
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: { type: 'select' },
|
||||||
|
options: ['primary', 'outline']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tags: ['autodocs']
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Primary button',
|
||||||
|
variant: 'primary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Outline: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Outline button',
|
||||||
|
variant: 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FullWidth: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Full width',
|
||||||
|
variant: 'primary',
|
||||||
|
fullWidth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/components/ui/Button.vue
Normal file
45
app/components/ui/Button.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="componentTag"
|
||||||
|
:type="componentType"
|
||||||
|
:class="['btn', variantClass, fullWidth ? 'w-full' : '']"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'button',
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
as: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: 'button', // button | NuxtLink | a | etc
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const componentTag = computed(() => {
|
||||||
|
if (props.as === 'NuxtLink') {
|
||||||
|
return resolveComponent('NuxtLink')
|
||||||
|
}
|
||||||
|
return props.as || 'button'
|
||||||
|
})
|
||||||
|
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
|
||||||
|
|
||||||
|
const variantClass = computed(() => {
|
||||||
|
if (props.variant === 'outline') return 'btn-outline btn-primary'
|
||||||
|
if (props.variant === 'ghost') return 'btn-ghost'
|
||||||
|
return 'btn-primary'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Card.stories.ts
Normal file
21
app/components/ui/Card.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Card.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Card',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
42
app/components/ui/Card.vue
Normal file
42
app/components/ui/Card.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="cardClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
padding: {
|
||||||
|
type: String,
|
||||||
|
default: 'medium', // none | small | medium
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: 'default', // default | muted | primary
|
||||||
|
},
|
||||||
|
interactive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const paddingMap: Record<string, string> = {
|
||||||
|
none: '',
|
||||||
|
small: 'p-4',
|
||||||
|
medium: 'p-6',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneMap: Record<string, string> = {
|
||||||
|
default: 'bg-base-100',
|
||||||
|
muted: 'bg-base-200',
|
||||||
|
primary: 'bg-primary/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = computed(() => {
|
||||||
|
const paddingClass = paddingMap[props.padding] || paddingMap.medium
|
||||||
|
const toneClass = toneMap[props.tone] || toneMap.default
|
||||||
|
const interactiveClass = props.interactive ? 'cursor-pointer hover:shadow-lg' : ''
|
||||||
|
const baseClass = 'card transition-all duration-200'
|
||||||
|
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Container.stories.ts
Normal file
21
app/components/ui/Container.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Container.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Container',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
29
app/components/ui/Container.vue
Normal file
29
app/components/ui/Container.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="containerClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'content', // content (1400px) | narrow (800px)
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeMap: Record<string, string> = {
|
||||||
|
content: 'max-w-7xl',
|
||||||
|
narrow: 'max-w-3xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClass = computed(() => {
|
||||||
|
const sizeClass = sizeMap[props.size] || sizeMap.content
|
||||||
|
const paddingClass = props.padding ? 'px-4 sm:px-6 lg:px-8' : ''
|
||||||
|
return ['mx-auto w-full', sizeClass, paddingClass].filter(Boolean).join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
50
app/components/ui/EmptyState.vue
Normal file
50
app/components/ui/EmptyState.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card bg-base-200 p-8 sm:p-12">
|
||||||
|
<div class="flex flex-col items-center text-center gap-4">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-base-300 flex items-center justify-center">
|
||||||
|
<slot name="icon">
|
||||||
|
<svg class="w-8 h-8 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
|
||||||
|
</svg>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-lg font-semibold text-base-content">{{ title }}</h3>
|
||||||
|
<p v-if="description" class="text-base-content/70 max-w-md">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionLabel" class="mt-2">
|
||||||
|
<NuxtLink v-if="actionTo" :to="actionTo">
|
||||||
|
<button class="btn btn-primary gap-2">
|
||||||
|
<Icon v-if="actionIcon" :name="actionIcon" size="18" />
|
||||||
|
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</NuxtLink>
|
||||||
|
<button v-else class="btn btn-primary gap-2" @click="$emit('action')">
|
||||||
|
<Icon v-if="actionIcon" :name="actionIcon" size="18" />
|
||||||
|
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||||
|
</svg>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actionLabel?: string
|
||||||
|
actionTo?: string
|
||||||
|
actionIcon?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'action'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
21
app/components/ui/FieldButton.stories.ts
Normal file
21
app/components/ui/FieldButton.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './FieldButton.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/FieldButton',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
31
app/components/ui/FieldButton.vue
Normal file
31
app/components/ui/FieldButton.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<button :type="type" class="input input-bordered w-full flex items-center justify-between text-left" v-bind="$attrs">
|
||||||
|
<span :class="value ? 'text-base-content' : 'text-base-content/60'">
|
||||||
|
{{ value || placeholder }}
|
||||||
|
</span>
|
||||||
|
<svg v-if="chevron" class="w-4 h-4 text-base-content/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'button',
|
||||||
|
},
|
||||||
|
chevron: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Grid.stories.ts
Normal file
21
app/components/ui/Grid.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Grid.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Grid',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
52
app/components/ui/Grid.vue
Normal file
52
app/components/ui/Grid.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="gridClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
cols: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
gap: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 6,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const colMap: Record<string, string> = {
|
||||||
|
'1': 'grid-cols-1',
|
||||||
|
'2': 'grid-cols-2',
|
||||||
|
'3': 'grid-cols-3',
|
||||||
|
'4': 'grid-cols-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
const gapMap: Record<string, string> = {
|
||||||
|
'4': 'gap-4',
|
||||||
|
'6': 'gap-6',
|
||||||
|
'8': 'gap-8',
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridClass = computed(() => {
|
||||||
|
const baseCols = colMap[String(props.cols)] || colMap['1']
|
||||||
|
const mdClass = props.md ? colMap[String(props.md)] : ''
|
||||||
|
const lgClass = props.lg ? colMap[String(props.lg)] : ''
|
||||||
|
const gapClass = gapMap[String(props.gap)] || gapMap['6']
|
||||||
|
|
||||||
|
const classes = ['grid', baseCols, gapClass]
|
||||||
|
if (mdClass) classes.push(`md:${mdClass}`)
|
||||||
|
if (lgClass) classes.push(`lg:${lgClass}`)
|
||||||
|
|
||||||
|
return classes.join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/GridItem.stories.ts
Normal file
21
app/components/ui/GridItem.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './GridItem.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/GridItem',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
32
app/components/ui/GridItem.vue
Normal file
32
app/components/ui/GridItem.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="itemClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
md: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const spanMap: Record<string, string> = {
|
||||||
|
'1': 'col-span-1',
|
||||||
|
'2': 'col-span-2',
|
||||||
|
'3': 'col-span-3',
|
||||||
|
'4': 'col-span-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemClass = computed(() => {
|
||||||
|
const classes = [] as string[]
|
||||||
|
if (props.md && spanMap[String(props.md)]) classes.push(`md:${spanMap[String(props.md)]}`)
|
||||||
|
if (props.lg && spanMap[String(props.lg)]) classes.push(`lg:${spanMap[String(props.lg)]}`)
|
||||||
|
return classes.join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Heading.stories.ts
Normal file
21
app/components/ui/Heading.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Heading.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Heading',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
45
app/components/ui/Heading.vue
Normal file
45
app/components/ui/Heading.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="tag" :class="headingClass">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
type: String,
|
||||||
|
default: 'bold',
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: 'default', // default | inverse | muted
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = computed(() => {
|
||||||
|
const n = Math.min(Math.max(props.level, 1), 4)
|
||||||
|
return `h${n}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const headingClass = computed(() => {
|
||||||
|
const sizeMap: Record<number, string> = {
|
||||||
|
1: 'text-3xl lg:text-4xl',
|
||||||
|
2: 'text-2xl lg:text-3xl',
|
||||||
|
3: 'text-xl lg:text-2xl',
|
||||||
|
4: 'text-lg',
|
||||||
|
}
|
||||||
|
const sizeClass = sizeMap[Math.min(Math.max(props.level, 1), 4)]
|
||||||
|
const weightClass = props.weight === 'semibold' ? 'font-semibold' : 'font-bold'
|
||||||
|
const toneMap: Record<string, string> = {
|
||||||
|
default: 'text-base-content',
|
||||||
|
inverse: 'text-primary-content',
|
||||||
|
muted: 'text-base-content/80',
|
||||||
|
}
|
||||||
|
const toneClass = toneMap[props.tone] || toneMap.default
|
||||||
|
return [sizeClass, weightClass, toneClass].join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/IconCircle.stories.ts
Normal file
21
app/components/ui/IconCircle.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './IconCircle.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/IconCircle',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
34
app/components/ui/IconCircle.vue
Normal file
34
app/components/ui/IconCircle.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="circleClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md', // md | lg
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary', // primary | neutral
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeMap: Record<string, string> = {
|
||||||
|
md: 'w-16 h-16 text-2xl',
|
||||||
|
lg: 'w-20 h-20 text-3xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneMap: Record<string, string> = {
|
||||||
|
primary: 'bg-primary/10 text-primary',
|
||||||
|
neutral: 'bg-base-200 text-base-content',
|
||||||
|
}
|
||||||
|
|
||||||
|
const circleClass = computed(() => {
|
||||||
|
const sizeClass = sizeMap[props.size] || sizeMap.md
|
||||||
|
const toneClass = toneMap[props.tone] || toneMap.primary
|
||||||
|
return ['rounded-full flex items-center justify-center', sizeClass, toneClass].join(' ')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
app/components/ui/Input.stories.ts
Normal file
21
app/components/ui/Input.stories.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import StoryComponent from './Input.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof StoryComponent> = {
|
||||||
|
title: 'Ui/Input',
|
||||||
|
component: StoryComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
components: { StoryComponent },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<StoryComponent v-bind="args" />'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {}
|
||||||
|
}
|
||||||
36
app/components/ui/Input.vue
Normal file
36
app/components/ui/Input.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<label v-if="label" class="w-full space-y-1">
|
||||||
|
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
||||||
|
<input
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-bind="$attrs"
|
||||||
|
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user