459 lines
16 KiB
Vue
459 lines
16 KiB
Vue
|
|
<template>
|
||
|
|
<div>
|
||
|
|
<div class="flex justify-between items-center mb-6">
|
||
|
|
<h2 class="text-xl font-semibold text-gray-900">ประกาศ</h2>
|
||
|
|
<q-btn
|
||
|
|
color="primary"
|
||
|
|
label="สร้างประกาศ"
|
||
|
|
icon="add"
|
||
|
|
@click="openDialog()"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="loading" class="flex justify-center py-10">
|
||
|
|
<q-spinner color="primary" size="40px" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else-if="announcements.length === 0" class="text-center py-10 text-gray-500">
|
||
|
|
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
||
|
|
<p>ยังไม่มีประกาศ</p>
|
||
|
|
<q-btn
|
||
|
|
color="primary"
|
||
|
|
label="สร้างประกาศแรก"
|
||
|
|
class="mt-4"
|
||
|
|
@click="openDialog()"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="space-y-4">
|
||
|
|
<q-card
|
||
|
|
v-for="announcement in announcements"
|
||
|
|
:key="announcement.id"
|
||
|
|
flat
|
||
|
|
bordered
|
||
|
|
class="rounded-lg"
|
||
|
|
>
|
||
|
|
<q-card-section>
|
||
|
|
<div class="flex items-start justify-between">
|
||
|
|
<div class="flex-1">
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<q-icon v-if="announcement.is_pinned" name="push_pin" color="orange" size="20px" />
|
||
|
|
<h3 class="font-semibold text-gray-900">{{ announcement.title.th }}</h3>
|
||
|
|
<q-badge :color="announcement.status === 'PUBLISHED' ? 'green' : 'grey'">
|
||
|
|
{{ announcement.status === 'PUBLISHED' ? 'เผยแพร่' : 'ฉบับร่าง' }}
|
||
|
|
</q-badge>
|
||
|
|
</div>
|
||
|
|
<p class="text-sm text-gray-500 mt-1">{{ announcement.title.en }}</p>
|
||
|
|
<p class="text-gray-600 mt-3 whitespace-pre-line">{{ announcement.content.th }}</p>
|
||
|
|
<p class="text-xs text-gray-400 mt-3">
|
||
|
|
สร้างเมื่อ {{ formatDate(announcement.created_at) }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div class="flex gap-1">
|
||
|
|
<q-btn flat round icon="edit" color="primary" size="sm" @click="openDialog(announcement)" />
|
||
|
|
<q-btn flat round icon="delete" color="negative" size="sm" @click="confirmDelete(announcement)" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</q-card-section>
|
||
|
|
</q-card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Announcement Dialog -->
|
||
|
|
<q-dialog v-model="showDialog" persistent>
|
||
|
|
<q-card style="min-width: 600px; max-width: 700px">
|
||
|
|
<q-card-section class="row items-center q-pb-none">
|
||
|
|
<div class="text-h6">{{ editing ? 'แก้ไขประกาศ' : 'สร้างประกาศใหม่' }}</div>
|
||
|
|
<q-space />
|
||
|
|
<q-btn icon="close" flat round dense @click="showDialog = false" />
|
||
|
|
</q-card-section>
|
||
|
|
|
||
|
|
<q-card-section>
|
||
|
|
<div class="space-y-4">
|
||
|
|
<q-input
|
||
|
|
v-model="form.title.th"
|
||
|
|
outlined
|
||
|
|
label="หัวข้อ (ภาษาไทย) *"
|
||
|
|
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
|
||
|
|
lazy-rules="ondemand"
|
||
|
|
hide-bottom-space
|
||
|
|
/>
|
||
|
|
<q-input
|
||
|
|
v-model="form.title.en"
|
||
|
|
outlined
|
||
|
|
label="หัวข้อ (English)"
|
||
|
|
/>
|
||
|
|
<q-input
|
||
|
|
v-model="form.content.th"
|
||
|
|
outlined
|
||
|
|
type="textarea"
|
||
|
|
rows="4"
|
||
|
|
label="เนื้อหา (ภาษาไทย) *"
|
||
|
|
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
|
||
|
|
lazy-rules="ondemand"
|
||
|
|
hide-bottom-space
|
||
|
|
/>
|
||
|
|
<q-input
|
||
|
|
v-model="form.content.en"
|
||
|
|
outlined
|
||
|
|
type="textarea"
|
||
|
|
rows="4"
|
||
|
|
label="เนื้อหา (English)"
|
||
|
|
/>
|
||
|
|
<div class="flex gap-4">
|
||
|
|
<q-toggle v-model="form.is_pinned" label="ปักหมุด" />
|
||
|
|
<q-select
|
||
|
|
v-model="form.status"
|
||
|
|
:options="[
|
||
|
|
{ label: 'ฉบับร่าง', value: 'DRAFT' },
|
||
|
|
{ label: 'เผยแพร่', value: 'PUBLISHED' }
|
||
|
|
]"
|
||
|
|
outlined
|
||
|
|
emit-value
|
||
|
|
map-options
|
||
|
|
label="สถานะ"
|
||
|
|
class="flex-1"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Published At -->
|
||
|
|
<div class="row q-col-gutter-sm" v-if="form.status === 'PUBLISHED'">
|
||
|
|
<div class="col-12 col-md-12">
|
||
|
|
<q-input filled v-model="form.published_at" label="วันเวลาที่เผยแพร่ (ไม่ระบุ = เผยแพร่ทันที)">
|
||
|
|
<template v-slot:prepend>
|
||
|
|
<q-icon name="event" class="cursor-pointer">
|
||
|
|
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||
|
|
<q-date v-model="form.published_at" mask="YYYY-MM-DD HH:mm">
|
||
|
|
<div class="row items-center justify-end">
|
||
|
|
<q-btn v-close-popup label="Close" color="primary" flat />
|
||
|
|
</div>
|
||
|
|
</q-date>
|
||
|
|
</q-popup-proxy>
|
||
|
|
</q-icon>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template v-slot:append>
|
||
|
|
<q-icon name="access_time" class="cursor-pointer">
|
||
|
|
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||
|
|
<q-time v-model="form.published_at" mask="YYYY-MM-DD HH:mm" format24h>
|
||
|
|
<div class="row items-center justify-end">
|
||
|
|
<q-btn v-close-popup label="Close" color="primary" flat />
|
||
|
|
</div>
|
||
|
|
</q-time>
|
||
|
|
</q-popup-proxy>
|
||
|
|
</q-icon>
|
||
|
|
</template>
|
||
|
|
</q-input>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Attachments Section -->
|
||
|
|
<div class="border rounded-lg p-4">
|
||
|
|
<div class="flex justify-between items-center mb-3">
|
||
|
|
<h4 class="font-semibold text-gray-700">ไฟล์แนบ</h4>
|
||
|
|
<q-btn
|
||
|
|
size="sm"
|
||
|
|
color="primary"
|
||
|
|
icon="attach_file"
|
||
|
|
label="เพิ่มไฟล์"
|
||
|
|
:loading="uploadingAttachment"
|
||
|
|
@click="triggerFileInput"
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
ref="fileInputRef"
|
||
|
|
type="file"
|
||
|
|
class="hidden"
|
||
|
|
@change="handleFileUpload"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Existing Attachments (edit mode) -->
|
||
|
|
<div v-if="editing?.attachments && editing.attachments.length > 0" class="space-y-2 mb-2">
|
||
|
|
<div
|
||
|
|
v-for="attachment in editing?.attachments"
|
||
|
|
:key="attachment.id"
|
||
|
|
class="flex items-center justify-between bg-gray-50 rounded p-2"
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<q-icon name="insert_drive_file" color="grey" />
|
||
|
|
<span class="text-sm">{{ attachment.file_name }}</span>
|
||
|
|
<span class="text-xs text-gray-400">({{ formatFileSize(attachment.file_size) }})</span>
|
||
|
|
</div>
|
||
|
|
<q-btn
|
||
|
|
flat
|
||
|
|
round
|
||
|
|
size="sm"
|
||
|
|
icon="delete"
|
||
|
|
color="negative"
|
||
|
|
:loading="deletingAttachmentId === attachment.id"
|
||
|
|
@click="deleteAttachment(attachment.id)"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Pending Files (create mode) -->
|
||
|
|
<div v-if="pendingFiles.length > 0" class="space-y-2 mb-2">
|
||
|
|
<div
|
||
|
|
v-for="(file, index) in pendingFiles"
|
||
|
|
:key="index"
|
||
|
|
class="flex items-center justify-between bg-blue-50 rounded p-2"
|
||
|
|
>
|
||
|
|
<div class="flex items-center gap-2">
|
||
|
|
<q-icon name="upload_file" color="primary" />
|
||
|
|
<span class="text-sm">{{ file.name }}</span>
|
||
|
|
<span class="text-xs text-gray-400">({{ formatFileSize(file.size) }})</span>
|
||
|
|
<q-badge color="blue" label="รอสร้าง" size="sm" />
|
||
|
|
</div>
|
||
|
|
<q-btn
|
||
|
|
flat
|
||
|
|
round
|
||
|
|
size="sm"
|
||
|
|
icon="close"
|
||
|
|
color="grey"
|
||
|
|
@click="removePendingFile(index)"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-if="!editing?.attachments?.length && pendingFiles.length === 0" class="text-center py-4 text-gray-400 text-sm">
|
||
|
|
ยังไม่มีไฟล์แนบ
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</q-card-section>
|
||
|
|
|
||
|
|
<q-card-actions align="right" class="q-pa-md">
|
||
|
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="showDialog = false" />
|
||
|
|
<q-btn
|
||
|
|
:label="editing ? 'บันทึก' : 'สร้าง'"
|
||
|
|
color="primary"
|
||
|
|
:loading="saving"
|
||
|
|
@click="save"
|
||
|
|
/>
|
||
|
|
</q-card-actions>
|
||
|
|
</q-card>
|
||
|
|
</q-dialog>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { useQuasar } from 'quasar';
|
||
|
|
import {
|
||
|
|
instructorService,
|
||
|
|
type AnnouncementResponse,
|
||
|
|
type CreateAnnouncementRequest
|
||
|
|
} from '~/services/instructor.service';
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
courseId: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const props = defineProps<Props>();
|
||
|
|
const $q = useQuasar();
|
||
|
|
|
||
|
|
// State
|
||
|
|
const announcements = ref<AnnouncementResponse[]>([]);
|
||
|
|
const loading = ref(false);
|
||
|
|
const showDialog = ref(false);
|
||
|
|
const editing = ref<AnnouncementResponse | null>(null);
|
||
|
|
const saving = ref(false);
|
||
|
|
|
||
|
|
// Form
|
||
|
|
const form = ref<CreateAnnouncementRequest>({
|
||
|
|
title: { th: '', en: '' },
|
||
|
|
content: { th: '', en: '' },
|
||
|
|
is_pinned: false,
|
||
|
|
status: 'DRAFT',
|
||
|
|
published_at: ''
|
||
|
|
});
|
||
|
|
|
||
|
|
// Attachments
|
||
|
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||
|
|
const uploadingAttachment = ref(false);
|
||
|
|
const deletingAttachmentId = ref<number | null>(null);
|
||
|
|
const pendingFiles = ref<File[]>([]);
|
||
|
|
|
||
|
|
// Methods
|
||
|
|
const fetchAnnouncements = async () => {
|
||
|
|
loading.value = true;
|
||
|
|
try {
|
||
|
|
announcements.value = await instructorService.getAnnouncements(props.courseId);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch announcements:', error);
|
||
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดประกาศได้', position: 'top' });
|
||
|
|
} finally {
|
||
|
|
loading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const openDialog = (announcement?: AnnouncementResponse) => {
|
||
|
|
editing.value = announcement || null;
|
||
|
|
if (announcement) {
|
||
|
|
// Format date for q-date/q-time (YYYY-MM-DD HH:mm)
|
||
|
|
let formattedPublishedAt = '';
|
||
|
|
if (announcement.published_at) {
|
||
|
|
const date = new Date(announcement.published_at);
|
||
|
|
const year = date.getFullYear();
|
||
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
|
|
const day = String(date.getDate()).padStart(2, '0');
|
||
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
||
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
|
|
formattedPublishedAt = `${year}-${month}-${day} ${hours}:${minutes}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
form.value = {
|
||
|
|
title: { ...announcement.title },
|
||
|
|
content: { ...announcement.content },
|
||
|
|
is_pinned: announcement.is_pinned,
|
||
|
|
status: announcement.status,
|
||
|
|
published_at: formattedPublishedAt
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
form.value = {
|
||
|
|
title: { th: '', en: '' },
|
||
|
|
content: { th: '', en: '' },
|
||
|
|
is_pinned: false,
|
||
|
|
status: 'DRAFT',
|
||
|
|
published_at: ''
|
||
|
|
};
|
||
|
|
}
|
||
|
|
pendingFiles.value = [];
|
||
|
|
showDialog.value = true;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Watch for published_at changes to auto-set status
|
||
|
|
watch(() => form.value.published_at, (newVal) => {
|
||
|
|
if (newVal) {
|
||
|
|
form.value.status = 'PUBLISHED';
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Watch for status changes to clear published_at if DRAFT
|
||
|
|
watch(() => form.value.status, (newVal) => {
|
||
|
|
if (newVal === 'DRAFT') {
|
||
|
|
form.value.published_at = '';
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const save = async () => {
|
||
|
|
if (!form.value.title.th || !form.value.content.th) {
|
||
|
|
$q.notify({ type: 'warning', message: 'กรุณากรอกข้อมูลที่จำเป็น', position: 'top' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
saving.value = true;
|
||
|
|
try {
|
||
|
|
if (editing.value) {
|
||
|
|
await instructorService.updateAnnouncement(props.courseId, editing.value.id, form.value);
|
||
|
|
$q.notify({ type: 'positive', message: 'บันทึกประกาศสำเร็จ', position: 'top' });
|
||
|
|
} else {
|
||
|
|
const created = await instructorService.createAnnouncement(props.courseId, form.value);
|
||
|
|
|
||
|
|
// Upload pending files
|
||
|
|
for (const file of pendingFiles.value) {
|
||
|
|
try {
|
||
|
|
await instructorService.uploadAnnouncementAttachment(props.courseId, created.data.id, file);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to upload attachment:', err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$q.notify({ type: 'positive', message: 'สร้างประกาศสำเร็จ', position: 'top' });
|
||
|
|
}
|
||
|
|
showDialog.value = false;
|
||
|
|
fetchAnnouncements();
|
||
|
|
} catch (error: any) {
|
||
|
|
$q.notify({
|
||
|
|
type: 'negative',
|
||
|
|
message: error.data?.message || 'ไม่สามารถบันทึกประกาศได้',
|
||
|
|
position: 'top'
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
saving.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const confirmDelete = (announcement: AnnouncementResponse) => {
|
||
|
|
$q.dialog({
|
||
|
|
title: 'ยืนยันการลบ',
|
||
|
|
message: `คุณต้องการลบประกาศ "${announcement.title.th}" หรือไม่?`,
|
||
|
|
cancel: true,
|
||
|
|
persistent: true
|
||
|
|
}).onOk(async () => {
|
||
|
|
try {
|
||
|
|
await instructorService.deleteAnnouncement(props.courseId, announcement.id);
|
||
|
|
$q.notify({ type: 'positive', message: 'ลบประกาศสำเร็จ', position: 'top' });
|
||
|
|
fetchAnnouncements();
|
||
|
|
} catch (error) {
|
||
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบประกาศได้', position: 'top' });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const triggerFileInput = () => {
|
||
|
|
fileInputRef.value?.click();
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleFileUpload = async (event: Event) => {
|
||
|
|
const input = event.target as HTMLInputElement;
|
||
|
|
const files = input.files;
|
||
|
|
if (!files || files.length === 0) return;
|
||
|
|
|
||
|
|
if (editing.value) {
|
||
|
|
// Upload immediately in edit mode
|
||
|
|
uploadingAttachment.value = true;
|
||
|
|
try {
|
||
|
|
await instructorService.uploadAnnouncementAttachment(props.courseId, editing.value.id, files[0]);
|
||
|
|
$q.notify({ type: 'positive', message: 'อัพโหลดไฟล์สำเร็จ', position: 'top' });
|
||
|
|
// Refresh editing
|
||
|
|
const all = await instructorService.getAnnouncements(props.courseId);
|
||
|
|
editing.value = all.find(a => a.id === editing.value?.id) || null;
|
||
|
|
} catch (error) {
|
||
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดไฟล์ได้', position: 'top' });
|
||
|
|
} finally {
|
||
|
|
uploadingAttachment.value = false;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Add to pending files in create mode
|
||
|
|
pendingFiles.value.push(files[0]);
|
||
|
|
}
|
||
|
|
|
||
|
|
input.value = '';
|
||
|
|
};
|
||
|
|
|
||
|
|
const removePendingFile = (index: number) => {
|
||
|
|
pendingFiles.value.splice(index, 1);
|
||
|
|
};
|
||
|
|
|
||
|
|
const deleteAttachment = async (attachmentId: number) => {
|
||
|
|
if (!editing.value) return;
|
||
|
|
deletingAttachmentId.value = attachmentId;
|
||
|
|
try {
|
||
|
|
await instructorService.deleteAnnouncementAttachment(props.courseId, editing.value.id, attachmentId);
|
||
|
|
$q.notify({ type: 'positive', message: 'ลบไฟล์สำเร็จ', position: 'top' });
|
||
|
|
// Refresh editing
|
||
|
|
const all = await instructorService.getAnnouncements(props.courseId);
|
||
|
|
editing.value = all.find(a => a.id === editing.value?.id) || null;
|
||
|
|
} catch (error) {
|
||
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบไฟล์ได้', position: 'top' });
|
||
|
|
} finally {
|
||
|
|
deletingAttachmentId.value = null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatDate = (dateStr: string) => {
|
||
|
|
const date = new Date(dateStr);
|
||
|
|
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatFileSize = (bytes: number) => {
|
||
|
|
if (bytes < 1024) return bytes + ' B';
|
||
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
|
|
};
|
||
|
|
|
||
|
|
// Fetch on mount
|
||
|
|
onMounted(() => {
|
||
|
|
fetchAnnouncements();
|
||
|
|
});
|
||
|
|
</script>
|