561 lines
14 KiB
Vue
561 lines
14 KiB
Vue
<template>
|
|
<section
|
|
v-if="attachments.length"
|
|
class="payment-attachments"
|
|
data-testid="payment-attachment-preview"
|
|
>
|
|
<div class="payment-attachments__header">
|
|
<div>
|
|
<h3>{{ t('taskV4.paymentAttachments.title') }}</h3>
|
|
<p v-if="loading">
|
|
{{ t('taskV4.paymentAttachments.loading') }}
|
|
</p>
|
|
<p v-else-if="loadErrorMessage">
|
|
{{ loadErrorMessage }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ul class="payment-attachments__grid">
|
|
<li
|
|
v-for="attachment in resolvedAttachments"
|
|
:key="attachment.key"
|
|
class="payment-attachment"
|
|
>
|
|
<button
|
|
v-if="canPreview(attachment)"
|
|
type="button"
|
|
class="payment-attachment__thumbnail"
|
|
data-testid="payment-attachment-thumbnail"
|
|
:aria-label="t('taskV4.paymentAttachments.openPreview')"
|
|
@click="selectedPreview = attachment"
|
|
>
|
|
<img
|
|
:src="attachment.mediaUrl"
|
|
:alt="attachment.fileName"
|
|
>
|
|
</button>
|
|
<div
|
|
v-else
|
|
class="payment-attachment__file"
|
|
aria-hidden="true"
|
|
>
|
|
{{ attachment.isImage ? 'IMG' : 'FILE' }}
|
|
</div>
|
|
|
|
<div class="payment-attachment__body">
|
|
<strong>{{ attachment.fileName }}</strong>
|
|
<small>{{ attachmentMeta(attachment) }}</small>
|
|
<small
|
|
v-if="attachmentStatus(attachment)"
|
|
class="payment-attachment__status"
|
|
>
|
|
{{ attachmentStatus(attachment) }}
|
|
</small>
|
|
</div>
|
|
|
|
<a
|
|
v-if="canDownload(attachment)"
|
|
class="payment-attachment__download"
|
|
data-testid="payment-attachment-download"
|
|
:href="attachment.mediaUrl"
|
|
:download="attachment.fileName"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{{ t('taskV4.paymentAttachments.download') }}
|
|
</a>
|
|
<span
|
|
v-else-if="!attachment.isImage"
|
|
class="payment-attachment__disabled"
|
|
>
|
|
{{ t('taskV4.paymentAttachments.downloadUnavailable') }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<div
|
|
v-if="selectedPreview"
|
|
class="payment-lightbox"
|
|
data-testid="payment-attachment-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@click.self="selectedPreview = null"
|
|
>
|
|
<div class="payment-lightbox__panel">
|
|
<button
|
|
type="button"
|
|
class="payment-lightbox__close"
|
|
:aria-label="t('taskV4.paymentAttachments.closePreview')"
|
|
@click="selectedPreview = null"
|
|
>
|
|
{{ t('taskV4.paymentAttachments.closePreview') }}
|
|
</button>
|
|
<img
|
|
:src="selectedPreview.mediaUrl"
|
|
:alt="selectedPreview.fileName"
|
|
>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
import { fetchSourceMessageConversation } from '@/services/reservationService'
|
|
import type {
|
|
ReservationRecord,
|
|
ReservationV4PaymentAttachmentSummary,
|
|
ReservationV4TaskCardResult,
|
|
SourceMessageOriginalMedia,
|
|
} from '@/types/reservation'
|
|
|
|
type PaymentAttachmentItem = {
|
|
key: string
|
|
attachmentId: string
|
|
externalMediaId: string
|
|
fileName: string
|
|
contentType: string
|
|
sizeBytes: number | null
|
|
isImage: boolean
|
|
previewAvailable: boolean
|
|
downloadAvailable: boolean
|
|
unavailableReason: string
|
|
}
|
|
|
|
type ResolvedPaymentAttachment = PaymentAttachmentItem & {
|
|
media: SourceMessageOriginalMedia | null
|
|
mediaUrl: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
card: ReservationV4TaskCardResult
|
|
sourceMessageId?: string | null
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const currentMessageMedia = ref<SourceMessageOriginalMedia[]>([])
|
|
const loading = ref(false)
|
|
const loadError = ref(false)
|
|
const selectedPreview = ref<ResolvedPaymentAttachment | null>(null)
|
|
let requestSequence = 0
|
|
|
|
const attachments = computed(() => normalizePaymentAttachments(props.card.display_payload?.payment_attachments))
|
|
const attachmentSignature = computed(() =>
|
|
attachments.value
|
|
.map((attachment) => `${attachment.externalMediaId}:${attachment.attachmentId}:${attachment.fileName}`)
|
|
.join('|'),
|
|
)
|
|
const loadErrorMessage = computed(() => {
|
|
if (!attachments.value.length) {
|
|
return ''
|
|
}
|
|
if (!props.sourceMessageId) {
|
|
return t('taskV4.paymentAttachments.missingSourceMessage')
|
|
}
|
|
return loadError.value ? t('taskV4.paymentAttachments.loadFailed') : ''
|
|
})
|
|
const resolvedAttachments = computed<ResolvedPaymentAttachment[]>(() =>
|
|
attachments.value.map((attachment) => {
|
|
const media = matchCurrentMessageMedia(attachment)
|
|
return {
|
|
...attachment,
|
|
media,
|
|
mediaUrl: media ? safeMediaUrl(media) : '',
|
|
}
|
|
}),
|
|
)
|
|
|
|
watch(
|
|
[() => props.sourceMessageId, attachmentSignature],
|
|
() => {
|
|
void loadCurrentMessageMedia()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
async function loadCurrentMessageMedia(): Promise<void> {
|
|
const currentRequest = ++requestSequence
|
|
currentMessageMedia.value = []
|
|
loadError.value = false
|
|
selectedPreview.value = null
|
|
|
|
if (!props.sourceMessageId || !attachments.value.length) {
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
try {
|
|
const conversation = await fetchSourceMessageConversation(props.sourceMessageId)
|
|
if (currentRequest !== requestSequence) {
|
|
return
|
|
}
|
|
const currentMessage = conversation.messages.find((message) =>
|
|
String(message.id) === String(props.sourceMessageId),
|
|
)
|
|
currentMessageMedia.value = currentMessage
|
|
? [...currentMessage.attachments, ...currentMessage.inline_images]
|
|
: []
|
|
} catch {
|
|
if (currentRequest === requestSequence) {
|
|
loadError.value = true
|
|
}
|
|
} finally {
|
|
if (currentRequest === requestSequence) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizePaymentAttachments(value: unknown): PaymentAttachmentItem[] {
|
|
if (!Array.isArray(value)) {
|
|
return []
|
|
}
|
|
return value
|
|
.map((item, index) => normalizePaymentAttachment(item, index))
|
|
.filter((item): item is PaymentAttachmentItem => Boolean(item))
|
|
}
|
|
|
|
function normalizePaymentAttachment(value: unknown, index: number): PaymentAttachmentItem | null {
|
|
const record = readRecord(value) as ReservationV4PaymentAttachmentSummary | null
|
|
if (!record) {
|
|
return null
|
|
}
|
|
|
|
const attachmentId = readFirstString(record, ['attachment_id', 'attachmentId', 'id'])
|
|
const externalMediaId = readFirstString(record, ['external_media_id', 'externalMediaId'])
|
|
const fileName = safeFileName(readFirstString(record, ['file_name', 'fileName', 'name', 'display_name']))
|
|
const contentType = readFirstString(record, ['content_type', 'contentType', 'mime_type']) ?? ''
|
|
const sizeBytes = readNumber(record, 'size_bytes') ?? readNumber(record, 'sizeBytes') ?? readNumber(record, 'size')
|
|
const isImage = readBoolean(record, 'is_image') ?? contentType.toLowerCase().startsWith('image/')
|
|
|
|
return {
|
|
key: externalMediaId || attachmentId || `${fileName}-${index}`,
|
|
attachmentId,
|
|
externalMediaId,
|
|
fileName,
|
|
contentType,
|
|
sizeBytes,
|
|
isImage,
|
|
previewAvailable: readBoolean(record, 'preview_available') ?? true,
|
|
downloadAvailable: readBoolean(record, 'download_available') ?? true,
|
|
unavailableReason: readFirstString(record, ['unavailable_reason_code', 'unavailableReasonCode']) ?? '',
|
|
}
|
|
}
|
|
|
|
function matchCurrentMessageMedia(attachment: PaymentAttachmentItem): SourceMessageOriginalMedia | null {
|
|
if (attachment.externalMediaId) {
|
|
const matchedByExternalMediaId = currentMessageMedia.value.find((media) =>
|
|
mediaExternalMediaId(media) === attachment.externalMediaId,
|
|
)
|
|
if (matchedByExternalMediaId) {
|
|
return matchedByExternalMediaId
|
|
}
|
|
}
|
|
if (attachment.attachmentId) {
|
|
return currentMessageMedia.value.find((media) =>
|
|
mediaAttachmentIds(media).includes(attachment.attachmentId),
|
|
) ?? null
|
|
}
|
|
return null
|
|
}
|
|
|
|
function canPreview(attachment: ResolvedPaymentAttachment): boolean {
|
|
return attachment.isImage &&
|
|
attachment.previewAvailable &&
|
|
Boolean(attachment.mediaUrl) &&
|
|
!loadError.value
|
|
}
|
|
|
|
function canDownload(attachment: ResolvedPaymentAttachment): boolean {
|
|
return !attachment.isImage &&
|
|
attachment.downloadAvailable &&
|
|
Boolean(attachment.mediaUrl) &&
|
|
!loadError.value
|
|
}
|
|
|
|
function attachmentStatus(attachment: ResolvedPaymentAttachment): string {
|
|
if (loadErrorMessage.value) {
|
|
return loadErrorMessage.value
|
|
}
|
|
if (!attachment.media) {
|
|
return t('taskV4.paymentAttachments.noMatchedMedia')
|
|
}
|
|
if (attachment.unavailableReason) {
|
|
return t('taskV4.paymentAttachments.unavailableReason', { reason: attachment.unavailableReason })
|
|
}
|
|
if (attachment.isImage && !attachment.previewAvailable) {
|
|
return t('taskV4.paymentAttachments.previewUnavailable')
|
|
}
|
|
if (!attachment.isImage && !attachment.downloadAvailable) {
|
|
return t('taskV4.paymentAttachments.downloadUnavailable')
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function attachmentMeta(attachment: PaymentAttachmentItem): string {
|
|
const type = attachment.contentType || (attachment.isImage ? 'image' : 'file')
|
|
const size = formatFileSize(attachment.sizeBytes)
|
|
return size
|
|
? t('taskV4.paymentAttachments.fileMeta', { type, size })
|
|
: type
|
|
}
|
|
|
|
function mediaExternalMediaId(media: SourceMessageOriginalMedia): string {
|
|
return media.externalMediaId ?? readFirstString(media as unknown as ReservationRecord, ['external_media_id']) ?? ''
|
|
}
|
|
|
|
function mediaAttachmentIds(media: SourceMessageOriginalMedia): string[] {
|
|
const record = media as unknown as ReservationRecord
|
|
return [
|
|
readFirstString(record, ['attachment_id', 'attachmentId', 'id']),
|
|
mediaExternalMediaId(media),
|
|
].filter(Boolean)
|
|
}
|
|
|
|
function safeMediaUrl(media: SourceMessageOriginalMedia): string {
|
|
return media.externalUrl ?? readFirstString(media as unknown as ReservationRecord, ['external_url', 'url']) ?? ''
|
|
}
|
|
|
|
function readRecord(value: unknown): ReservationRecord | null {
|
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
? value as ReservationRecord
|
|
: null
|
|
}
|
|
|
|
function readFirstString(record: ReservationRecord, keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = record[key]
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value.trim()
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function readNumber(record: ReservationRecord, key: string): number | null {
|
|
const value = record[key]
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
if (typeof value === 'string' && Number.isFinite(Number(value))) {
|
|
return Number(value)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readBoolean(record: ReservationRecord, key: string): boolean | null {
|
|
const value = record[key]
|
|
if (typeof value === 'boolean') {
|
|
return value
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalizedValue = value.trim().toLowerCase()
|
|
if (normalizedValue === 'true') {
|
|
return true
|
|
}
|
|
if (normalizedValue === 'false') {
|
|
return false
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function safeFileName(value: string): string {
|
|
return value || t('conversation.unnamedAttachment')
|
|
}
|
|
|
|
function formatFileSize(size: number | null): string {
|
|
if (!size) {
|
|
return ''
|
|
}
|
|
if (size < 1024) {
|
|
return `${size} B`
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${Math.round(size / 1024)} KB`
|
|
}
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.payment-attachments {
|
|
display: grid;
|
|
gap: 12px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-slate-50);
|
|
padding: 14px;
|
|
}
|
|
|
|
.payment-attachments__header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
|
|
.payment-attachments__header h3,
|
|
.payment-attachments__header p {
|
|
margin: 0;
|
|
}
|
|
|
|
.payment-attachments__header h3 {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.payment-attachments__header p {
|
|
margin-top: 4px;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.payment-attachments__grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
gap: 10px;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.payment-attachment {
|
|
display: grid;
|
|
grid-template-columns: 88px minmax(0, 1fr) auto;
|
|
gap: 12px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
list-style: none;
|
|
padding: 10px;
|
|
}
|
|
|
|
.payment-attachment__thumbnail,
|
|
.payment-attachment__file {
|
|
width: 88px;
|
|
height: 64px;
|
|
border: 1px solid var(--th-color-slate-200);
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-slate-50);
|
|
}
|
|
|
|
.payment-attachment__thumbnail {
|
|
cursor: zoom-in;
|
|
overflow: hidden;
|
|
padding: 0;
|
|
}
|
|
|
|
.payment-attachment__thumbnail img {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.payment-attachment__file {
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.payment-attachment__body {
|
|
display: grid;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.payment-attachment__body strong {
|
|
color: var(--th-color-slate-900);
|
|
font-size: 13px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.payment-attachment__body small,
|
|
.payment-attachment__disabled {
|
|
color: var(--th-color-slate-500);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.payment-attachment__status {
|
|
color: var(--th-color-warning);
|
|
}
|
|
|
|
.payment-attachment__download {
|
|
color: var(--th-color-blue-600);
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.payment-attachment__download:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.payment-lightbox {
|
|
position: fixed;
|
|
z-index: 50;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
background: rgb(15 23 42 / 68%);
|
|
padding: 24px;
|
|
}
|
|
|
|
.payment-lightbox__panel {
|
|
display: grid;
|
|
gap: 12px;
|
|
max-width: min(960px, 92vw);
|
|
max-height: 92vh;
|
|
}
|
|
|
|
.payment-lightbox__panel img {
|
|
max-width: 100%;
|
|
max-height: 78vh;
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
object-fit: contain;
|
|
}
|
|
|
|
.payment-lightbox__close {
|
|
justify-self: end;
|
|
min-height: 36px;
|
|
border: 0;
|
|
border-radius: var(--th-radius-sm);
|
|
background: var(--th-color-white);
|
|
color: var(--th-color-slate-900);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.payment-attachment {
|
|
grid-template-columns: 72px minmax(0, 1fr);
|
|
}
|
|
|
|
.payment-attachment__thumbnail,
|
|
.payment-attachment__file {
|
|
width: 72px;
|
|
height: 56px;
|
|
}
|
|
|
|
.payment-attachment__download,
|
|
.payment-attachment__disabled {
|
|
grid-column: 2;
|
|
justify-self: start;
|
|
}
|
|
}
|
|
</style>
|