411 lines
15 KiB
Vue
411 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, watch } from 'vue';
|
|
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
|
import SessionUser from "@/components/session/token/SessionUser.vue";
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
const { t } = useI18n();
|
|
|
|
type attachment = {
|
|
id: number;
|
|
object_type: string;
|
|
object_id: number;
|
|
content: {
|
|
image: string | null;
|
|
document: string | null;
|
|
relation: string | null;
|
|
other: unknown;
|
|
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
|
|
};
|
|
created_at: string;
|
|
updated_at: string | null;
|
|
deleted_at: string | null;
|
|
}
|
|
|
|
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
|
|
|
|
const getAttachmentContent = (attachmentEntry: attachment) => {
|
|
if (!attachmentEntry.content) {
|
|
return {
|
|
image: null,
|
|
document: null,
|
|
relation: null,
|
|
other: null,
|
|
src: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
image: attachmentEntry.content.image ?? null,
|
|
document: attachmentEntry.content.document ?? null,
|
|
relation: attachmentEntry.content.relation ?? null,
|
|
other: attachmentEntry.content.other ?? null,
|
|
src: attachmentEntry.content.src ?? null,
|
|
};
|
|
};
|
|
|
|
const getAttachmentOtherText = (attachmentEntry: attachment) => {
|
|
const other = getAttachmentContent(attachmentEntry).other;
|
|
if (typeof other === 'string') {
|
|
return other;
|
|
}
|
|
|
|
if (isSelfServeWashAttachment(attachmentEntry)) {
|
|
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
|
|
return customerNumber
|
|
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
|
|
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
|
|
}
|
|
|
|
return other && typeof other === 'object' ? JSON.stringify(other) : '';
|
|
};
|
|
const props = defineProps({
|
|
attachments: {
|
|
type: Array as () => attachment[],
|
|
required: true
|
|
},
|
|
getPreviewLink: {
|
|
type: Function as () => (id: number) => Promise<string>,
|
|
required: true
|
|
},
|
|
showAttachWashCertificateAction: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
const emits = defineEmits<{
|
|
(e: 'delete', id: number): void;
|
|
(e: 'upload', payload: { filename: string; base64String: string | ArrayBuffer | null }): void;
|
|
(e: 'download', id: number): void;
|
|
(e: 'attach-wash-certificate'): void;
|
|
}>();
|
|
|
|
const onClickDelete = (id: number) => {
|
|
emits('delete', id);
|
|
}
|
|
|
|
const determineAttachmentType = (attachment: attachment): 'image' | 'document' | 'relation' | 'other' | 'unknown' => {
|
|
const content = getAttachmentContent(attachment);
|
|
if (content.image) return 'image';
|
|
if (content.document) return 'document';
|
|
if (content.relation) return 'relation';
|
|
if (content.other) return 'other';
|
|
return 'unknown';
|
|
};
|
|
|
|
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
|
|
const other = getAttachmentContent(attachmentEntry).other;
|
|
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
|
|
? other as Record<string, any>
|
|
: null;
|
|
};
|
|
|
|
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
|
|
return getSelfServeWashPayload(attachmentEntry) !== null;
|
|
};
|
|
|
|
const formatSelfServeDriver = (payload: Record<string, any>): string => {
|
|
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
|
|
};
|
|
|
|
const formatElapsedMinutes = (seconds: unknown): string => {
|
|
const parsed = Number(seconds);
|
|
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
|
|
};
|
|
|
|
const getAttachmentTypeIcon = (attachment: attachment): string => {
|
|
const type = determineAttachmentType(attachment);
|
|
switch (type) {
|
|
case 'image':
|
|
return 'fas fa-image';
|
|
case 'document':
|
|
return 'fas fa-file-alt';
|
|
case 'relation':
|
|
return 'fas fa-link';
|
|
case 'other':
|
|
return 'fas fa-ellipsis-h';
|
|
default:
|
|
return 'fas fa-question';
|
|
}
|
|
};
|
|
|
|
const isAttachmentTypeIn = (types: ('image' | 'document' | 'relation' | 'other' | 'unknown')[]): boolean => {
|
|
return props.attachments.some(att => types.includes(determineAttachmentType(att)));
|
|
};
|
|
const isOfficeDocument = (attachmentEntry: attachment): boolean => {
|
|
const otherText = getAttachmentOtherText(attachmentEntry).toLowerCase();
|
|
return ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'].some(extension => otherText.endsWith(extension));
|
|
};
|
|
const previewRequestsInFlight = new Set<number>();
|
|
const generatedObjectUrls = new Set<string>();
|
|
|
|
const isObjectUrl = (url: string | null): boolean => Boolean(url && url.startsWith('blob:'));
|
|
|
|
const releaseObjectUrl = (url: string | null): void => {
|
|
if (!isObjectUrl(url)) {
|
|
return;
|
|
}
|
|
URL.revokeObjectURL(url as string);
|
|
generatedObjectUrls.delete(url as string);
|
|
};
|
|
|
|
const shouldLoadDocumentPreview = (attachmentEntry: attachment): boolean => {
|
|
const content = getAttachmentContent(attachmentEntry);
|
|
return determineAttachmentType(attachmentEntry) === 'document'
|
|
&& Boolean(content.document)
|
|
&& !isOfficeDocument(attachmentEntry)
|
|
&& !content.src;
|
|
};
|
|
|
|
const createEmbeddablePreviewUrl = async (downloadLink: string | null): Promise<string | null> => {
|
|
if (!downloadLink) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(downloadLink, { method: 'GET' });
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const fileBlob = await response.blob();
|
|
if (!fileBlob || fileBlob.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
const objectUrl = URL.createObjectURL(fileBlob);
|
|
generatedObjectUrls.add(objectUrl);
|
|
return objectUrl;
|
|
} catch (error) {
|
|
console.warn('Unable to create embeddable preview blob', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const loadPreviewLink = async (id: number): Promise<void> => {
|
|
if (previewRequestsInFlight.has(id)) {
|
|
return;
|
|
}
|
|
|
|
previewRequestsInFlight.add(id);
|
|
try {
|
|
const downloadLink = await props.getPreviewLink(id);
|
|
const src = await createEmbeddablePreviewUrl(downloadLink);
|
|
const attachment = props.attachments.find(att => att.id === id);
|
|
if (!attachment) {
|
|
return;
|
|
}
|
|
|
|
const content = getAttachmentContent(attachment);
|
|
if (content.src && content.src !== src) {
|
|
releaseObjectUrl(content.src);
|
|
}
|
|
content.src = src || null;
|
|
attachment.content = content;
|
|
} catch (error) {
|
|
console.warn('Error loading preview link for attachment', id, error);
|
|
} finally {
|
|
previewRequestsInFlight.delete(id);
|
|
}
|
|
};
|
|
|
|
watch(
|
|
() => props.attachments.map((attachment) => `${attachment.id}:${getAttachmentContent(attachment).src ?? ''}:${getAttachmentOtherText(attachment)}`),
|
|
() => {
|
|
props.attachments.forEach((attachment) => {
|
|
if (shouldLoadDocumentPreview(attachment)) {
|
|
void loadPreviewLink(attachment.id);
|
|
}
|
|
});
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onBeforeUnmount(() => {
|
|
generatedObjectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
generatedObjectUrls.clear();
|
|
});
|
|
|
|
const uploadAttachment = (file: File) => {
|
|
// Implement the upload logic here
|
|
console.warn("Uploading file:", file);
|
|
// Base64 encode the file to easily display it
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const base64String = e.target?.result;
|
|
const filename = file.name;
|
|
emits('upload', { filename, base64String });
|
|
}
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const onClickUpload = () => {
|
|
// Trigger file input click
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = 'image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx'; // Accept images and common document types
|
|
fileInput.onchange = (event: Event) => {
|
|
const target = event.target as HTMLInputElement;
|
|
if (target.files && target.files[0]) {
|
|
uploadAttachment(target.files[0]);
|
|
}
|
|
};
|
|
fileInput.click();
|
|
};
|
|
|
|
const onClickAttachWashCertificate = () => {
|
|
emits('attach-wash-certificate');
|
|
};
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="is-fullwidth">
|
|
<div class="columns is-mobile is-multiline is-variable is-1">
|
|
<!-- Add Attachment Button -->
|
|
<div class="column is-12">
|
|
<WhiteBoxCard :toggleable="true" data-testid="pos-order-attachments-add-card">
|
|
<template #header>
|
|
<div class="card-header-icon">
|
|
<span class="icon">
|
|
<i class="fas fa-plus" aria-hidden="true"></i>
|
|
</span>
|
|
</div>
|
|
<div class="card-header-title">
|
|
{{ t('admin.pos.attachments_add') }}
|
|
</div>
|
|
<div class="card-header-icon">
|
|
<span class="icon">
|
|
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<!-- Content for adding a new attachment can go here -->
|
|
<p>{{ t('admin.pos.attachments_upload_description') }}</p>
|
|
</template>
|
|
<template #footer>
|
|
<a
|
|
class="card-footer-item"
|
|
data-testid="pos-order-attachments-upload-action"
|
|
@click="onClickUpload"
|
|
>
|
|
<!-- Footer actions can go here -->
|
|
{{ t('admin.pos.attachments_upload_action') }}
|
|
</a>
|
|
<a
|
|
v-if="props.showAttachWashCertificateAction"
|
|
class="card-footer-item"
|
|
data-testid="pos-order-attachments-attach-wash-certificate"
|
|
@click="onClickAttachWashCertificate"
|
|
>
|
|
{{ t('admin.pos.settings_wheel.attach_wash_certificate') }}
|
|
</a>
|
|
</template>
|
|
</WhiteBoxCard>
|
|
</div>
|
|
<!-- Existing Attachments -->
|
|
<template v-for="attachment in props.attachments" :key="attachment.id">
|
|
<div class="column is-12">
|
|
<WhiteBoxCard :toggleable="true" :data-testid="`pos-order-attachment-card-${attachment.id}`">
|
|
<template #header>
|
|
<div class="card-header-icon">
|
|
<span class="icon">
|
|
<i class="fas fa-paperclip" aria-hidden="true"></i>
|
|
</span>
|
|
<span class="icon">
|
|
<i :class="getAttachmentTypeIcon(attachment)" aria-hidden="true"></i>
|
|
</span>
|
|
</div>
|
|
<div class="card-header-title">
|
|
{{ t('admin.pos.attachment') }} #{{ attachment.id }}
|
|
</div>
|
|
<div class="card-header-icon">
|
|
<span class="icon">
|
|
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
|
</span>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<!--{{ attachment }}-->
|
|
<!-- DOCUMENT PREVIEW -->
|
|
<template v-if="determineAttachmentType(attachment) === 'document' && getAttachmentContent(attachment).document">
|
|
<!-- If the file extension is office document, you can use an online viewer -->
|
|
<template v-if="isOfficeDocument(attachment)">
|
|
<span>{{ t('admin.pos.attachments_office_preview_unavailable') }} <a @click="$emit('download', attachment.id)">{{ t('admin.pos.attachments_download_action') }}</a></span>
|
|
</template>
|
|
<iframe
|
|
v-else-if="getAttachmentContent(attachment).src"
|
|
:src="getAttachmentContent(attachment).src || ''"
|
|
style="width: 100%; height: 400px; border: none;"
|
|
></iframe>
|
|
<span v-else>{{ t('admin.pos.attachments_no_preview') }}</span>
|
|
</template>
|
|
<!-- OTHER PREVIEW -->
|
|
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
|
|
<template v-if="isSelfServeWashAttachment(attachment)">
|
|
<div class="content is-size-7">
|
|
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
|
|
<p>
|
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
|
|
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
|
|
</p>
|
|
<p>
|
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
|
|
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
|
|
</p>
|
|
<p>
|
|
<strong>{{ t('pos.license_plate') }}:</strong>
|
|
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
|
|
</p>
|
|
<p>
|
|
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
|
|
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<!-- If the other type is a URL, you can create a link -->
|
|
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')">
|
|
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer">
|
|
{{ getAttachmentContent(attachment).other }}
|
|
</a>
|
|
</template>
|
|
<template v-else>
|
|
<span>{{ getAttachmentOtherText(attachment) }}</span>
|
|
</template>
|
|
</template>
|
|
<!-- NO PREVIEW -->
|
|
<template v-else>
|
|
<span>{{ t('admin.pos.attachments_no_preview') }}</span>
|
|
</template>
|
|
</template>
|
|
<template #footer>
|
|
<div class="card-footer-item">
|
|
{{ t('common.created') }}: {{ new Date(attachment.created_at).toLocaleString() }}
|
|
</div>
|
|
<a class="card-footer-item" v-if="!attachment.deleted_at" @click="onClickDelete(attachment.id)">
|
|
<!-- Delete button or action can be placed here -->
|
|
{{ t('admin.pos.delete') }}
|
|
</a>
|
|
<!-- Download, if applicable -->
|
|
<a
|
|
class="card-footer-item"
|
|
:data-testid="`pos-order-attachment-download-${attachment.id}`"
|
|
v-if="isAttachmentTypeIn(['image', 'document']) && (getAttachmentContent(attachment).image || getAttachmentContent(attachment).document)"
|
|
@click="$emit('download', attachment.id)"
|
|
>
|
|
{{ t('admin.pos.attachments_download_action') }}
|
|
</a>
|
|
</template>
|
|
</WhiteBoxCard>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
</style>
|
|
|