Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/atrium-telegram/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ watch(colorMode, () => {
// Init Stores
const user = useUserStore()
const task = useTaskStore()
const epic = useEpicStore()
const ticket = useTicketStore()
const kitchen = useKitchenStore()
const flow = useFlowStore()

Expand All @@ -79,7 +79,7 @@ onMounted(async () => {
user.updateOnline(),
user.update(),
task.update(),
epic.update(),
ticket.update(),
kitchen.update(),
flow.update(),
])
Expand All @@ -89,11 +89,11 @@ onMounted(async () => {
user.updateOnline(),
user.update(),
task.update(),
epic.update(),
ticket.update(),
kitchen.update(),
flow.update(),
])
}, 30000)
}, 20000)
})

onUnmounted(() => {
Expand Down
38 changes: 38 additions & 0 deletions apps/atrium-telegram/app/components/TicketCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<ActiveCard>
<UIcon name="i-lucide-mail-question-mark" class="size-8 text-primary" />

<h3 class="text-xl/5 font-bold">
{{ ticket.title }}
</h3>

<div class="w-full text-base/5 font-normal whitespace-pre-wrap break-words line-clamp-5">
{{ ticket.description }}
</div>

<div class="flex justify-between items-center">
<div class="flex flex-row gap-4">
<div class="flex flex-row gap-1.5 items-center text-muted">
<UIcon name="i-lucide-message-circle" class="size-5" />
<p>{{ ticket?.messages.length }}</p>
</div>
</div>

<time
:datetime="ticket.updatedAt"
class="text-sm text-muted"
v-text="format(new Date(ticket.updatedAt), 'обновлен d MMMM yyyy', { locale: ru })"
/>
</div>
</ActiveCard>
</template>

<script setup lang="ts">
import type { TicketWithData } from '~/stores/ticket'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale/ru'

defineProps<{
ticket: TicketWithData
}>()
</script>
135 changes: 135 additions & 0 deletions apps/atrium-telegram/app/components/TicketMessage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div class="flex flex-row gap-2 items-start">
<div class="mt-2.5">
<UAvatar :src="user?.avatarUrl ?? undefined" />
</div>
<div class="w-full flex flex-col gap-1.5">
<UDropdownMenu
:items="items"
:ui="{
content: 'w-56',
item: 'p-2 motion-preset-slide-left motion-duration-200',
}"
:content="{
sideOffset: -32,
}"
>
<ActiveCard>
<div class="w-full relative flex flex-col justify-between gap-2">
<div class="flex flex-col gap-1">
<div class="text-base/5 whitespace-break-spaces text-default font-medium">
{{ message?.text }}
</div>

<div v-if="message?.fileUrl">
<UButton
variant="solid"
color="secondary"
:icon="getFileIcon(message.fileType)"
@click="handleFileClick(message.fileUrl)"
>
Прикрепленный файл
</UButton>
</div>

<div v-if="message?.createdAt" class="mt-1 flex justify-end text-xs text-muted">
{{ format(new Date(message.createdAt), 'dd MMMM в HH:mm', { locale: ru }) }}
</div>
</div>
</div>
</ActiveCard>
</UDropdownMenu>

<!-- <div v-if="message?.notifications?.length" class="-mt-4 ml-4 flex flex-row flex-wrap gap-1">
<UserBeacon
v-for="notification in comment.notifications"
:key="notification.id"
:notification="notification"
/>
</div> -->
</div>
</div>
</template>

<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale/ru'

const { ticketId, messageId } = defineProps<{
ticketId: string
messageId: string
}>()

// const overlay = useOverlay()
// const modalCreateEpicCommentBeacon = overlay.create(ModalCreateEpicCommentBeacon)

const ticketStore = useTicketStore()
const userStore = useUserStore()

const ticket = computed(() => ticketStore.tickets.find((t) => t.id === ticketId))
const message = computed(() => ticket.value?.messages.find((m) => m.id === messageId))
const user = computed(() => userStore.find(message.value?.userId ?? ''))

const items = computed<DropdownMenuItem[]>(() => {
const menuItems: DropdownMenuItem[] = [
{
label: 'Скопировать сообщение',
icon: 'i-lucide-copy',
color: 'neutral',
disabled: false,
onSelect: () => navigator.clipboard.writeText(message.value?.text ?? ''),
condition: true,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Ensure proper handling of undefined condition property

The TypeScript type DropdownMenuItem might not include a condition property based on the Nuxt UI types. This could cause TypeScript errors or runtime issues.


🌐 Web query:

Does the DropdownMenuItem type from @nuxt/ui include a condition property?

💡 Result:

No. The official DropdownMenuItem type in @nuxt/ui does not include a "condition" property — the documented item properties (label, icon, avatar, kbds, type, color, checked, disabled, slot, onSelect, onUpdateChecked, children, class, ui, etc.) do not list "condition". [1][2]


Remove/guard unknown "condition" on DropdownMenuItem objects

The @nuxt/ui DropdownMenuItem type does not include a "condition" property; keeping it in an object literal will cause TypeScript errors.

  • Fix: apps/atrium-telegram/app/components/TicketMessage.vue — remove condition: true (line 82) and compute/filter visibility before creating DropdownMenuItem objects.
  • Alternative: if you need the field, map items to the library's shape or explicitly cast/augment the type (e.g., as DropdownMenuItem & { condition?: boolean } or module augmentation).
🤖 Prompt for AI Agents
In apps/atrium-telegram/app/components/TicketMessage.vue around line 82, the
DropdownMenuItem objects include an unsupported "condition" property which
causes TypeScript errors; remove the `condition: true` entry from the object
literal and instead compute visibility before constructing the DropdownMenuItem
array (filter items based on your condition), or if you must keep the field for
internal logic map your items into the library's expected shape or explicitly
cast/augment the type (e.g., map to DropdownMenuItem & { condition?: boolean })
so the final objects passed to the DropdownMenu accept the correct type.

},
// {
// label: 'Маякнуть (будет позже)',
// icon: 'i-lucide-users-round',
// color: 'neutral',
// disabled: true,
// onSelect: () => modalCreateEpicCommentBeacon.open({ messageId }),
// condition: true,
// },
{
label: 'Лайкнуть (будет позже)',
icon: 'i-lucide-thumbs-up',
color: 'neutral',
disabled: true,
onSelect: () => {},
condition: user.value?.id !== userStore.id,
},
{
label: 'Редактировать',
icon: 'i-lucide-edit',
disabled: true,
onSelect: () => {},
condition: user.value?.id === userStore.id,
},
{
label: 'Удалить',
icon: 'i-lucide-trash-2',
disabled: true,
onSelect: () => {},
condition: user.value?.id === userStore.id,
},
]

return menuItems.filter((item) => item.condition)
})

function getFileIcon(type: 'image' | 'video' | 'document' | null) {
switch (type) {
case 'image':
return 'i-lucide-image'
case 'video':
return 'i-lucide-video'
case 'document':
return 'i-lucide-file'
default:
return 'i-lucide-file'
}
}

function handleFileClick(fileUrl: string) {
window.open(fileUrl, '_blank')
}
</script>
8 changes: 4 additions & 4 deletions apps/atrium-telegram/app/composables/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ function _useNavigation() {
badge: flowStore.nowViewedItemsCount.toString(),
},
{
path: '/epic',
names: ['epic', 'epic-epicId'],
title: t('app.epics'),
icon: 'i-lucide-crown',
path: '/ticket',
names: ['ticket', 'ticket-ticketId'],
title: t('app.tickets'),
icon: 'i-lucide-mail-question-mark',
},
{
path: '/tasks',
Expand Down
4 changes: 4 additions & 0 deletions apps/atrium-telegram/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
</NuxtLink>
</div>
</div>

<div class="mt-16 flex flex-row justify-center">
<UIcon name="i-lucide-route" class="size-8 text-dimmed/25" />
</div>
</PageContainer>
</template>

Expand Down
3 changes: 3 additions & 0 deletions apps/atrium-telegram/app/pages/startapp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ if (tgWebAppStartParam?.length && tgWebAppStartParam.includes(separator)) {
case 'epic':
await navigateTo({ path: `/epic/${value}`, query })
break
case 'ticket':
await navigateTo({ path: `/ticket/${value}`, query })
break
default:
await navigateTo('/')
}
Expand Down
79 changes: 79 additions & 0 deletions apps/atrium-telegram/app/pages/ticket/[ticketId]/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<template>
<PageContainer>
<Section>
<div class="flex flex-row items-start justify-between gap-2.5">
<UIcon name="i-lucide-mail-question-mark" class="size-10 text-primary" />
</div>

<h1 class="text-2xl/6 font-bold">
{{ ticket?.title }}
</h1>

<div class="w-full text-base/5 whitespace-pre-wrap break-words">
{{ ticket?.description }}
</div>
</Section>

<Section class="flex flex-row justify-between items-center">
<div class="flex flex-row items-center gap-2">
<UIcon name="i-lucide-message-circle" class="size-5" />
{{ ticket?.messages.length }} {{ pluralizationRu(ticket?.messages.length ?? 0, ['сообщение', 'сообщения', 'сообщений']) }}
</div>
</Section>

<div class="w-full flex flex-col gap-3.5 flex-1 last-of-type:mb-20">
<TicketMessage
v-for="message in messages"
:key="message.id"
:ticket-id="message.ticketId"
:message-id="message.id"
/>

<UButton
v-if="isShowMore"
variant="solid"
color="secondary"
size="xl"
class="w-full items-center justify-center"
icon="i-lucide-message-circle"
:label="$t('common.show-more')"
@click="shownMessages += 10"
/>
</div>

<!-- <UDrawer v-model:open="isDrawerOpened">
<CreateCard
v-if="epic?.id"
:label="$t('app.create.epic-comment.button')"
icon="i-lucide-message-circle"
/>

<template #body>
<FormCreateEpicComment
:epic-id="epic?.id ?? ''"
@submitted="isDrawerOpened = false"
@success="isDrawerOpened = false"
/>
</template>
</UDrawer> -->
</PageContainer>
</template>

<script setup lang="ts">
definePageMeta({
name: 'ticket-ticketId',
canReturn: true,
})

const { params } = useRoute('ticket-ticketId')

const ticketStore = useTicketStore()
const ticket = computed(() => ticketStore.tickets.find((e) => e.id === params.ticketId))

// On load show last 10 messages. On button click = show more 10 messages
const shownMessages = ref(10)
const messages = computed(() => ticket.value?.messages.slice(0, shownMessages.value))
const isShowMore = computed<boolean>(() => messages.value?.length && ticket.value?.messages.length ? messages.value.length < ticket.value.messages.length : false)
Comment on lines +74 to +76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Simplify computed property logic and fix potential edge cases

The messages computed property could return undefined if ticket is not found, and the isShowMore logic is unnecessarily complex with redundant checks.

-const messages = computed(() => ticket.value?.messages.slice(0, shownMessages.value))
-const isShowMore = computed<boolean>(() => messages.value?.length && ticket.value?.messages.length ? messages.value.length < ticket.value.messages.length : false)
+const messages = computed(() => ticket.value?.messages.slice(0, shownMessages.value) ?? [])
+const isShowMore = computed<boolean>(() => {
+  if (!ticket.value?.messages.length) return false
+  return messages.value.length < ticket.value.messages.length
+})

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/atrium-telegram/app/pages/ticket/[ticketId]/index.vue around lines
74-76, the messages computed may return undefined when ticket or ticket.messages
is missing and isShowMore has redundant checks; change messages to always return
an array (e.g., use (ticket.value?.messages ?? []).slice(0,
shownMessages.value)) and simplify isShowMore to compare lengths against a safe
number (e.g., messages.value.length < (ticket.value?.messages?.length ?? 0)) so
it handles missing data and removes unnecessary conditional logic.


// const isDrawerOpened = ref(false)
</script>
22 changes: 22 additions & 0 deletions apps/atrium-telegram/app/pages/ticket/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<PageContainer>
<NuxtLink
v-for="ticket of ticketStore.tickets"
:key="ticket.id"
:to="`/ticket/${ticket.id}`"
class="motion-preset-slide-left"
>
<TicketCard :ticket="ticket">
{{ ticket.title }}
</TicketCard>
</NuxtLink>

<div class="mt-16 flex flex-row justify-center">
<UIcon name="i-lucide-route" class="size-8 text-dimmed/25" />
</div>
</PageContainer>
</template>

<script setup lang="ts">
const ticketStore = useTicketStore()
</script>
44 changes: 44 additions & 0 deletions apps/atrium-telegram/app/stores/ticket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Ticket, TicketMessage, User } from '@roll-stack/database'
import { initDataRaw as _initDataRaw, useSignal } from '@telegram-apps/sdk-vue'

export type TicketWithData = Ticket & {
messages: TicketMessage[]
lastMessage: TicketMessage | null
user: User
}

export const useTicketStore = defineStore('ticket', () => {
const tickets = ref<TicketWithData[]>([])

const initDataRaw = useSignal(_initDataRaw)

async function update() {
try {
const data = await $fetch('/api/ticket/list', {
headers: {
Authorization: `tma ${initDataRaw.value}`,
},
})
if (!data) {
return
}

tickets.value = data
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('401')) {
// No session
}
if (error.message.includes('404')) {
// Not found
}
}
}
Comment on lines +28 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Improve error handling implementation

The current error handling checks for status codes via string matching in error messages, which is fragile and non-standard. Additionally, the error handlers don't perform any actions, making them ineffective.

-    } catch (error) {
-      if (error instanceof Error) {
-        if (error.message.includes('401')) {
-          // No session
-        }
-        if (error.message.includes('404')) {
-          // Not found
-        }
-      }
+    } catch (error: any) {
+      // Handle FetchError from $fetch
+      if (error?.data?.statusCode === 401 || error?.statusCode === 401) {
+        // No session - could redirect to login or show auth prompt
+        console.error('Authentication required for ticket access')
+        tickets.value = []
+      } else if (error?.data?.statusCode === 404 || error?.statusCode === 404) {
+        // Not found - likely means no tickets endpoint
+        console.error('Ticket endpoint not found')
+        tickets.value = []
+      } else {
+        // Log unexpected errors
+        console.error('Failed to fetch tickets:', error)
+        throw error // Re-throw to let caller handle if needed
+      }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (error instanceof Error) {
if (error.message.includes('401')) {
// No session
}
if (error.message.includes('404')) {
// Not found
}
}
}
} catch (error: any) {
// Handle FetchError from $fetch
if (error?.data?.statusCode === 401 || error?.statusCode === 401) {
// No session - could redirect to login or show auth prompt
console.error('Authentication required for ticket access')
tickets.value = []
} else if (error?.data?.statusCode === 404 || error?.statusCode === 404) {
// Not found - likely means no tickets endpoint
console.error('Ticket endpoint not found')
tickets.value = []
} else {
// Log unexpected errors
console.error('Failed to fetch tickets:', error)
throw error // Re-throw to let caller handle if needed
}
}
🤖 Prompt for AI Agents
In apps/atrium-telegram/app/stores/ticket.ts around lines 28-36, the code is
doing fragile string-matching on error.message for status codes and no-ops
inside those branches; replace this with robust type-aware checks (e.g., if the
error is an AxiosError or has error.response?.status) and switch on
error.response.status (401, 404) instead of message.includes; inside the 401
branch clear any auth/session state and trigger a logout or session-refresh flow
and log the event, and inside the 404 branch set a notFound flag or return a
specific error value so callers can handle it, while for other errors rethrow or
log the full error to preserve diagnostics.

}

return {
tickets,

update,
}
})
Loading