Initial commit
This commit is contained in:
509
app/components/application/ContractPaymentUpload.vue
Normal file
509
app/components/application/ContractPaymentUpload.vue
Normal file
@@ -0,0 +1,509 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading Overlay -->
|
||||
<div v-if="isLoading" class="loading-overlay">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="mb-5 pb-3" style="border-bottom: 2px solid #e8e8e8;">
|
||||
<div class="is-flex is-align-items-center is-gap-3">
|
||||
<div class="buttons has-addons mb-0">
|
||||
<button @click="viewMode = 'list'" :class="['button', viewMode === 'list' ? 'is-primary' : 'is-light']">
|
||||
Danh sách
|
||||
</button>
|
||||
<button @click="viewMode = 'gallery'" :class="['button', viewMode === 'gallery' ? 'is-primary' : 'is-light']">
|
||||
Thư viện
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-if="$getEditRights() && currentReservation?.status === 5 && supplementNotes.length > 0"
|
||||
class="button is-danger is-light" @click="showSupplementNotes">
|
||||
<span>Thông tin thiếu ({{ supplementNotes.length }})</span>
|
||||
</button>
|
||||
|
||||
<button v-if="$getEditRights() && currentReservation?.status === 5" class="button is-warning"
|
||||
:class="{ 'is-loading': isSubmitting }" :disabled="isSubmitting" @click="confirmSubmitForApproval">
|
||||
Xác nhận đã hoàn thành bổ sung thông tin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Document Types List -->
|
||||
<div v-if="phasedoctypes && phasedoctypes.length > 0">
|
||||
<div v-for="doctype in phasedoctypes" :key="doctype.id" class="mb-6">
|
||||
<div class="level is-mobile mb-4">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
<p class="is-size-6 has-text-weight-semibold has-text-primary">
|
||||
{{ doctype.doctype__name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<FileUpload v-if="$getEditRights()" :type="['file', 'image', 'pdf']"
|
||||
@files="(files) => handleUpload(files, doctype.doctype)" position="right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'">
|
||||
<div v-if="getFilesByDocType(doctype.doctype).length > 0">
|
||||
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
|
||||
class="is-flex is-justify-content-space-between is-align-items-center py-3 px-4 has-background-warning has-text-white"
|
||||
style="border-bottom: #e8e8e8 solid 1px; transition: all 0.2s ease; opacity: 0.95; cursor: pointer;"
|
||||
@mouseenter="$event.currentTarget.style.opacity = '1'"
|
||||
@mouseleave="$event.currentTarget.style.opacity = '0.95'">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" style="word-break: break-word;">
|
||||
{{ file.name || file.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-white-bis">
|
||||
{{ $formatFileSize(file.file__size) }} • {{ $dayjs(file.create_time).format("DD/MM/YYYY HH:mm") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="buttons are-small ml-3">
|
||||
<button @click="viewFile(file)" class="button has-background-white has-text-primary">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'view.svg', type: 'success', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="downloadFile(file)" class="button has-background-white has-text-primary">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'download.svg', type: 'success', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'bin.svg', type: 'danger', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
|
||||
Chưa có file nào
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gallery View -->
|
||||
<div v-if="viewMode === 'gallery'">
|
||||
<div v-if="getFilesByDocType(doctype.doctype).length > 0" class="columns is-multiline is-variable is-2">
|
||||
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
|
||||
class="column is-half-tablet is-one-third-desktop">
|
||||
<div class="has-background-warning has-text-white h-full"
|
||||
style="border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(50, 115, 220, 0.2);"
|
||||
@mouseenter="$event.currentTarget.style.transform = 'translateY(-4px)'; $event.currentTarget.style.boxShadow = '0 6px 16px rgba(50, 115, 220, 0.3)'"
|
||||
@mouseleave="$event.currentTarget.style.transform = 'translateY(0)'; $event.currentTarget.style.boxShadow = '0 2px 8px rgba(50, 115, 220, 0.2)'">
|
||||
<div
|
||||
style="flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(255, 255, 255, 0.1); min-height: 140px;">
|
||||
<div
|
||||
v-if="isImage(file.file__name)"
|
||||
class="w-full h-full is-flex is-justify-content-center is-align-items-center"
|
||||
>
|
||||
<img :src="`${$getpath()}static/files/${file.file__file}`" :alt="file.file__name"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div v-else class="has-text-white-ter" style="font-size: 48px; line-height: 1;">
|
||||
FILE
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" :title="file.file__name"
|
||||
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
{{ file.file__name }}
|
||||
</p>
|
||||
<p class="is-size-7 has-text-white-bis mb-3">{{ $formatFileSize(file.file__size) }}</p>
|
||||
<div class="buttons are-small is-centered">
|
||||
<button @click="viewFile(file)" class="button has-background-white has-text-primary">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'view.svg', type: 'success', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="downloadFile(file)" class="button has-background-white has-text-primary">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'download.svg', type: 'success', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger">
|
||||
<span class="icon">
|
||||
<SvgIcon v-bind="{ name: 'bin.svg', type: 'danger', size: 18 }"></SvgIcon>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
|
||||
Chưa có file nào
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- If no phase doctypes -->
|
||||
<div v-else-if="!isLoading" class="has-text-centered py-6">
|
||||
<p class="has-text-grey-light is-size-7">Chưa có loại tài liệu được định nghĩa cho giai đoạn này.</p>
|
||||
</div>
|
||||
|
||||
<!-- Supplement Notes Modal -->
|
||||
<div v-if="showNotesModal" class="modal is-active">
|
||||
<div class="modal-background" @click="showNotesModal = false"></div>
|
||||
<div class="modal-card" style="width: 600px; max-width: 90vw;">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">Yêu cầu bổ sung thông tin</p>
|
||||
<button class="delete" @click="showNotesModal = false"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div v-for="(note, idx) in supplementNotes" :key="idx" class="notification is-warning is-light mb-3">
|
||||
<p class="is-size-7 has-text-grey mb-1">
|
||||
{{ $dayjs(note.create_time).format('DD/MM/YYYY HH:mm') }}
|
||||
</p>
|
||||
<p>{{ note.detail }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button" @click="showNotesModal = false">Đóng</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal @close="showmodal = undefined" @modalevent="handleModalEvent" v-bind="showmodal" v-if="showmodal"></Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContractPaymentUpload",
|
||||
setup() {
|
||||
const { $formatFileSize, $dayjs, $getpath } = useNuxtApp();
|
||||
return { $formatFileSize, $dayjs, $getpath };
|
||||
},
|
||||
props: {
|
||||
row: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
transaction: null,
|
||||
currentReservation: null,
|
||||
productNotes: [],
|
||||
files: [],
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
showmodal: undefined,
|
||||
showNotesModal: false,
|
||||
phasedoctypes: [],
|
||||
viewMode: 'list',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
supplementNotes() {
|
||||
return this.productNotes.filter(note =>
|
||||
note.detail &&
|
||||
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
|
||||
!note.detail.includes('(đã bổ sung)')
|
||||
);
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const transactionId = this.row.id;
|
||||
this.transaction = await $getdata("transaction", { id: transactionId }, undefined, true);
|
||||
|
||||
if (this.transaction?.txncurrent__detail) {
|
||||
this.currentReservation = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail
|
||||
}, undefined, true);
|
||||
}
|
||||
|
||||
if (this.transaction?.product) {
|
||||
await this.fetchProductNotes();
|
||||
}
|
||||
|
||||
if (this.transaction?.phase) {
|
||||
await this.fetchPhaseDoctypes();
|
||||
}
|
||||
|
||||
await this.fetchFiles();
|
||||
} catch (error) {
|
||||
console.error("Error during component creation:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchProductNotes() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
if (!this.transaction?.product) return;
|
||||
try {
|
||||
const notes = await $getdata('productnote', { ref: this.transaction.product }, undefined, false);
|
||||
this.productNotes = Array.isArray(notes) ? notes : (notes ? [notes] : []);
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi tải product notes:", error);
|
||||
this.productNotes = [];
|
||||
}
|
||||
},
|
||||
|
||||
showSupplementNotes() {
|
||||
this.showNotesModal = true;
|
||||
},
|
||||
|
||||
async fetchPhaseDoctypes() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
if (!this.transaction?.phase) return;
|
||||
try {
|
||||
const phasedoctypesData = await $getdata('phasedoctype', {
|
||||
phase: this.transaction.phase,
|
||||
}, undefined, false);
|
||||
if (phasedoctypesData) {
|
||||
this.phasedoctypes = Array.isArray(phasedoctypesData) ? phasedoctypesData : [phasedoctypesData];
|
||||
} else {
|
||||
this.phasedoctypes = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi tải phase doctypes:", error);
|
||||
this.phasedoctypes = [];
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFiles() {
|
||||
const { $getdata } = useNuxtApp();
|
||||
if (!this.row.id) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const detail = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail
|
||||
}, undefined, true);
|
||||
const filesArray = await $getdata('transactionfile', {
|
||||
txn_detail: detail.id,
|
||||
}, undefined, false);
|
||||
if (filesArray) {
|
||||
this.files = (Array.isArray(filesArray) ? filesArray : [filesArray]).sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
|
||||
} else {
|
||||
this.files = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi tải danh sách file:", error);
|
||||
this.files = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
getFilesByDocType(docTypeId) {
|
||||
return this.files.filter(file => file.file__doc_type === docTypeId || (file.file__doc_type == null && docTypeId == null));
|
||||
},
|
||||
|
||||
getFileExtension(fileName) {
|
||||
return fileName ? fileName.split('.').pop().toLowerCase() : 'file';
|
||||
},
|
||||
|
||||
isImage(fileName) {
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext);
|
||||
},
|
||||
|
||||
isViewableDocument(fileName) {
|
||||
const ext = this.getFileExtension(fileName);
|
||||
return ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext);
|
||||
},
|
||||
|
||||
async handleUpload(uploadedFiles, docTypeId) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) return;
|
||||
this.isLoading = true;
|
||||
const { $patchapi, $getdata, $insertapi } = useNuxtApp();
|
||||
try {
|
||||
for (const fileRecord of uploadedFiles) {
|
||||
if (docTypeId) {
|
||||
await $patchapi('file', { id: fileRecord.id, doc_type: docTypeId });
|
||||
}
|
||||
const detail = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail,
|
||||
}, undefined, true);
|
||||
const payload = {
|
||||
txn_detail: detail.id,
|
||||
file: fileRecord.id,
|
||||
phase: this.transaction?.phase,
|
||||
};
|
||||
const result = await $insertapi("transactionfile", payload);
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error || "Lưu file không thành công.");
|
||||
}
|
||||
}
|
||||
await this.fetchFiles();
|
||||
this.$emit('upload-completed');
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi lưu file:", error);
|
||||
alert("Đã xảy ra lỗi khi tải file lên. Vui lòng thử lại.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
downloadFile(file) {
|
||||
const { $getpath } = useNuxtApp();
|
||||
const filePath = file.file__file || file.file;
|
||||
if (!filePath) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${$getpath()}static/files/${filePath}`;
|
||||
link.download = file.file__name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
deleteFile(fileId) {
|
||||
this.showmodal = {
|
||||
component: 'dialog/Confirm',
|
||||
title: 'Xác nhận xóa',
|
||||
height: '10vh',
|
||||
width: '40%',
|
||||
vbind: { content: 'Bạn có chắc chắn muốn xóa file này không?' },
|
||||
onConfirm: async () => {
|
||||
this.isLoading = true;
|
||||
const { $deleteapi } = useNuxtApp();
|
||||
try {
|
||||
const result = await $deleteapi("transactionfile", fileId);
|
||||
if (result && !result.error) {
|
||||
await this.fetchFiles();
|
||||
} else {
|
||||
throw new Error(result.error || "Xóa file không thành công.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi xóa file:", error);
|
||||
alert("Đã xảy ra lỗi khi xóa file. Vui lòng thử lại.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
confirmSubmitForApproval() {
|
||||
this.showmodal = {
|
||||
component: 'dialog/Confirm',
|
||||
title: 'Xác nhận hoàn thành bổ sung',
|
||||
height: '10vh',
|
||||
width: '40%',
|
||||
vbind: {
|
||||
content: 'Bạn có chắc chắn đã hoàn thành bổ sung thông tin và muốn nộp lại để chờ duyệt không?'
|
||||
},
|
||||
onConfirm: () => this.submitForApproval()
|
||||
};
|
||||
},
|
||||
|
||||
async submitForApproval() {
|
||||
if (this.isSubmitting) return;
|
||||
this.isSubmitting = true;
|
||||
const { $patchapi, $getdata, $snackbar, $updateapi } = useNuxtApp();
|
||||
try {
|
||||
// Cập nhật tất cả note chưa có "(đã bổ sung)" → thêm vào cuối
|
||||
const notesToUpdate = this.productNotes.filter(note =>
|
||||
note.detail &&
|
||||
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
|
||||
!note.detail.includes('(đã bổ sung)')
|
||||
);
|
||||
|
||||
await Promise.all(notesToUpdate.map(note =>
|
||||
$updateapi('productnote', {
|
||||
...note,
|
||||
detail: note.detail + ' (đã bổ sung)'
|
||||
})
|
||||
));
|
||||
|
||||
// Patch reservation status về 2
|
||||
await $patchapi('reservation', {
|
||||
id: this.currentReservation.id,
|
||||
status: 2
|
||||
});
|
||||
|
||||
// Refresh
|
||||
this.currentReservation = await $getdata('reservation', {
|
||||
id: this.transaction.txncurrent__detail
|
||||
}, undefined, true);
|
||||
await this.fetchProductNotes();
|
||||
|
||||
$snackbar('Xác nhận hoàn thành bổ sung thông tin thành công', 'Success', 'Success');
|
||||
this.$emit('status-updated');
|
||||
} catch (error) {
|
||||
console.error('Lỗi khi cập nhật trạng thái:', error);
|
||||
$snackbar('Lỗi khi cập nhật trạng thái', 'Error', 'Danger');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleModalEvent(event) {
|
||||
if (event.name === 'confirm' && typeof this.showmodal?.onConfirm === 'function') {
|
||||
this.showmodal.onConfirm();
|
||||
}
|
||||
},
|
||||
|
||||
viewFile(file) {
|
||||
const { $getpath } = useNuxtApp();
|
||||
const fileName = file.file__name || '';
|
||||
const filePath = file.file__file || file.file;
|
||||
if (!filePath) return;
|
||||
const isImageFile = this.isImage(fileName);
|
||||
const isViewable = this.isViewableDocument(fileName);
|
||||
const fileUrl = `${$getpath()}static/files/${filePath}`;
|
||||
if (isImageFile) {
|
||||
this.showmodal = {
|
||||
title: fileName,
|
||||
component: 'media/ChipImage',
|
||||
vbind: {
|
||||
extend: false,
|
||||
file: file,
|
||||
image: fileUrl,
|
||||
show: ['download', 'delete']
|
||||
}
|
||||
};
|
||||
} else if (isViewable) {
|
||||
const viewerUrl = `https://docs.google.com/gview?url=${fileUrl}&embedded=false`;
|
||||
window.open(viewerUrl, '_blank');
|
||||
} else {
|
||||
this.downloadFile(file);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3273dc;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user