|
|
@@ -0,0 +1,379 @@
|
|
|
+<template>
|
|
|
+ <q-dialog ref="dialogRef" :persistent="loading" @hide="onDialogHide">
|
|
|
+ <q-card class="q-dialog-plugin overflow-hidden" style="width: 820px; max-width: 95vw">
|
|
|
+ <DefaultDialogHeader :title="title" @close="onDialogCancel" />
|
|
|
+ <q-form ref="formRef" @submit="onOKClick">
|
|
|
+ <q-card-section class="scroll q-pt-none" style="max-height: 70vh">
|
|
|
+ <div class="row q-col-gutter-sm">
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.title"
|
|
|
+ v-model:error="validationErrors.title"
|
|
|
+ :label="$t('company_settings.event.title')"
|
|
|
+ :rules="[inputRules.required]"
|
|
|
+ class="col-12 col-md-8"
|
|
|
+ />
|
|
|
+ <DefaultSelect
|
|
|
+ v-model="form.type"
|
|
|
+ v-model:error="validationErrors.type"
|
|
|
+ :options="typeOptions"
|
|
|
+ :label="$t('company_settings.event.type')"
|
|
|
+ emit-value
|
|
|
+ map-options
|
|
|
+ class="col-12 col-md-4"
|
|
|
+ />
|
|
|
+ <DefaultInputDatePicker
|
|
|
+ v-model:untreated-date="form.date"
|
|
|
+ v-model:error="validationErrors.date"
|
|
|
+ :label="$t('company_settings.event.date')"
|
|
|
+ class="col-12 col-md-4"
|
|
|
+ />
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.time"
|
|
|
+ v-model:error="validationErrors.time"
|
|
|
+ :label="$t('company_settings.event.time')"
|
|
|
+ mask="##:##"
|
|
|
+ placeholder="00:00"
|
|
|
+ clearable
|
|
|
+ class="col-12 col-md-4"
|
|
|
+ >
|
|
|
+ <template #append>
|
|
|
+ <q-icon name="mdi-clock-outline" class="cursor-pointer">
|
|
|
+ <q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
|
|
+ <q-time v-model="form.time" format24h color="violet-normal">
|
|
|
+ <div class="row items-center justify-end">
|
|
|
+ <q-btn v-close-popup label="OK" color="violet-normal" flat />
|
|
|
+ </div>
|
|
|
+ </q-time>
|
|
|
+ </q-popup-proxy>
|
|
|
+ </q-icon>
|
|
|
+ </template>
|
|
|
+ </DefaultInput>
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.description"
|
|
|
+ v-model:error="validationErrors.description"
|
|
|
+ :label="$t('common.terms.description')"
|
|
|
+ type="textarea"
|
|
|
+ :rows="4"
|
|
|
+ class="col-12"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="col-12 q-mt-sm">
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.event.cover") }}</div>
|
|
|
+ <div class="row items-start q-col-gutter-sm">
|
|
|
+ <div class="col-6 col-sm-4">
|
|
|
+ <DefaultFilePicker
|
|
|
+ v-model="coverFile"
|
|
|
+ type="image"
|
|
|
+ :label="null"
|
|
|
+ :initial-image="coverPreview"
|
|
|
+ @update:model-value="onCoverSelected"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div v-if="coverPreview || coverFile" class="col-auto">
|
|
|
+ <q-btn
|
|
|
+ flat
|
|
|
+ dense
|
|
|
+ color="negative"
|
|
|
+ icon="mdi-delete"
|
|
|
+ padding="8px 10px"
|
|
|
+ :loading="removingCover"
|
|
|
+ :title="$t('company_settings.event.remove_cover')"
|
|
|
+ @click="onRemoveCover"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-if="uploadingCover" class="text-caption text-grey-6 q-mt-xs">
|
|
|
+ <q-spinner size="16px" class="q-mr-xs" />{{ $t("company_settings.event.uploading") }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="col-12 q-mt-sm">
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.event.gallery") }}</div>
|
|
|
+ <div class="row q-col-gutter-sm">
|
|
|
+ <div class="col-6 col-sm-3">
|
|
|
+ <DefaultFilePicker
|
|
|
+ type="image"
|
|
|
+ :label="null"
|
|
|
+ multiple
|
|
|
+ @files="onNewMediaSelected"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-for="media in existingMedia" :key="media.id" class="col-6 col-sm-3">
|
|
|
+ <div class="media-thumb">
|
|
|
+ <img :src="media.url" :alt="media.name" class="media-thumb-img" />
|
|
|
+ <q-btn
|
|
|
+ round unelevated dense size="xs"
|
|
|
+ color="negative"
|
|
|
+ icon="mdi-close"
|
|
|
+ class="media-thumb-delete"
|
|
|
+ :loading="deletingMediaId === media.id"
|
|
|
+ @click="onDeleteMedia(media)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-for="(pending, index) in pendingMedia" :key="`pending-${index}`" class="col-6 col-sm-3">
|
|
|
+ <div class="media-thumb">
|
|
|
+ <img :src="pending.preview" :alt="pending.file.name" class="media-thumb-img" />
|
|
|
+ <q-btn
|
|
|
+ round unelevated dense size="xs"
|
|
|
+ color="negative"
|
|
|
+ icon="mdi-close"
|
|
|
+ class="media-thumb-delete"
|
|
|
+ @click="removePendingMedia(index)"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="uploadingMedia" class="text-caption text-grey-6 q-mt-sm">
|
|
|
+ <q-spinner size="16px" class="q-mr-xs" />
|
|
|
+ {{ uploadTotal > 1
|
|
|
+ ? $t("company_settings.event.uploading_count", { done: uploadDone + 1, total: uploadTotal })
|
|
|
+ : $t("company_settings.event.uploading") }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </q-card-section>
|
|
|
+ <q-card-actions>
|
|
|
+ <q-space />
|
|
|
+ <q-btn
|
|
|
+ outline
|
|
|
+ color="negative"
|
|
|
+ :label="$t('common.actions.cancel')"
|
|
|
+ :disable="loading"
|
|
|
+ @click="onDialogCancel"
|
|
|
+ />
|
|
|
+ <q-btn
|
|
|
+ color="primary"
|
|
|
+ :label="event ? $t('common.actions.save') : $t('common.actions.add')"
|
|
|
+ type="submit"
|
|
|
+ :loading="loading"
|
|
|
+ />
|
|
|
+ </q-card-actions>
|
|
|
+ </q-form>
|
|
|
+ </q-card>
|
|
|
+ </q-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onUnmounted, useTemplateRef } from "vue";
|
|
|
+import { useDialogPluginComponent, useQuasar } from "quasar";
|
|
|
+import { useI18n } from "vue-i18n";
|
|
|
+import { useInputRules } from "src/composables/useInputRules";
|
|
|
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
|
|
|
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
|
|
|
+import {
|
|
|
+ createEvent,
|
|
|
+ updateEvent,
|
|
|
+ uploadEventCover,
|
|
|
+ removeEventCover,
|
|
|
+ uploadEventMedia,
|
|
|
+ deleteEventMedia,
|
|
|
+} from "src/api/event";
|
|
|
+
|
|
|
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
|
|
|
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
|
|
|
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
|
|
|
+import DefaultFilePicker from "src/components/defaults/DefaultFilePicker.vue";
|
|
|
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
|
|
|
+
|
|
|
+defineEmits([...useDialogPluginComponent.emits]);
|
|
|
+
|
|
|
+const { event, title } = defineProps({
|
|
|
+ event: {
|
|
|
+ type: Object,
|
|
|
+ default: null,
|
|
|
+ },
|
|
|
+ title: {
|
|
|
+ type: Function,
|
|
|
+ default: () => useI18n().t("company_settings.event.dialog_title"),
|
|
|
+ },
|
|
|
+});
|
|
|
+
|
|
|
+const $q = useQuasar();
|
|
|
+const { t } = useI18n();
|
|
|
+const { inputRules } = useInputRules();
|
|
|
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
|
|
|
+const formRef = useTemplateRef("formRef");
|
|
|
+
|
|
|
+const typeOptions = computed(() => [
|
|
|
+ { label: t("company_settings.event.type_cultura"), value: "cultura" },
|
|
|
+ { label: t("company_settings.event.type_esporte"), value: "esporte" },
|
|
|
+]);
|
|
|
+
|
|
|
+const { form, getUpdatedFields } = useFormUpdateTracker({
|
|
|
+ title: event?.title ?? "",
|
|
|
+ description: event?.description ?? "",
|
|
|
+ date: event?.date ?? null,
|
|
|
+ time: event?.time ?? null,
|
|
|
+ type: event?.type ?? "cultura",
|
|
|
+});
|
|
|
+
|
|
|
+const coverFile = ref(null);
|
|
|
+const coverPreview = ref(event?.cover_url ?? null);
|
|
|
+const uploadingCover = ref(false);
|
|
|
+const removingCover = ref(false);
|
|
|
+
|
|
|
+const existingMedia = ref([...(event?.media ?? [])]);
|
|
|
+const pendingMedia = ref([]);
|
|
|
+const uploadingMedia = ref(false);
|
|
|
+const uploadDone = ref(0);
|
|
|
+const uploadTotal = ref(0);
|
|
|
+const deletingMediaId = ref(null);
|
|
|
+
|
|
|
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
|
|
|
+ onSuccess: () => {
|
|
|
+ $q.notify({ type: "positive", message: t("common.ui.messages.saved") });
|
|
|
+ onDialogOK(true);
|
|
|
+ },
|
|
|
+ formRef,
|
|
|
+});
|
|
|
+
|
|
|
+const onCoverSelected = async (file) => {
|
|
|
+ if (!(file instanceof File)) return;
|
|
|
+
|
|
|
+ if (!event) {
|
|
|
+ coverPreview.value = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ uploadingCover.value = true;
|
|
|
+ try {
|
|
|
+ const updated = await uploadEventCover(event.id, file);
|
|
|
+ coverPreview.value = updated.cover_url;
|
|
|
+ coverFile.value = null;
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("http.errors.failed") });
|
|
|
+ } finally {
|
|
|
+ uploadingCover.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onRemoveCover = async () => {
|
|
|
+ if (!event || !coverPreview.value) {
|
|
|
+ coverFile.value = null;
|
|
|
+ coverPreview.value = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ removingCover.value = true;
|
|
|
+ try {
|
|
|
+ await removeEventCover(event.id);
|
|
|
+ coverFile.value = null;
|
|
|
+ coverPreview.value = null;
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("http.errors.failed") });
|
|
|
+ } finally {
|
|
|
+ removingCover.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onNewMediaSelected = async (files) => {
|
|
|
+ const selected = files.filter((file) => file instanceof File);
|
|
|
+ if (!selected.length) return;
|
|
|
+
|
|
|
+ if (!event) {
|
|
|
+ selected.forEach((file) => {
|
|
|
+ pendingMedia.value.push({ file, preview: URL.createObjectURL(file) });
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ uploadTotal.value = selected.length;
|
|
|
+ uploadDone.value = 0;
|
|
|
+ uploadingMedia.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ for (const file of selected) {
|
|
|
+ const media = await uploadEventMedia(event.id, file);
|
|
|
+ existingMedia.value.push(media);
|
|
|
+ uploadDone.value += 1;
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("http.errors.failed") });
|
|
|
+ } finally {
|
|
|
+ uploadingMedia.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const removePendingMedia = (index) => {
|
|
|
+ URL.revokeObjectURL(pendingMedia.value[index].preview);
|
|
|
+ pendingMedia.value.splice(index, 1);
|
|
|
+};
|
|
|
+
|
|
|
+const onDeleteMedia = async (media) => {
|
|
|
+ deletingMediaId.value = media.id;
|
|
|
+ try {
|
|
|
+ await deleteEventMedia(event.id, media.id);
|
|
|
+ existingMedia.value = existingMedia.value.filter((m) => m.id !== media.id);
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("http.errors.failed") });
|
|
|
+ } finally {
|
|
|
+ deletingMediaId.value = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onOKClick = async () => {
|
|
|
+ if (event) {
|
|
|
+ await submitForm(() =>
|
|
|
+ updateEvent(event.id, getUpdatedFields.value, { skipSuccessNotify: true }),
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ await submitForm(async () => {
|
|
|
+ const created = await createEvent({ ...form }, { skipSuccessNotify: true });
|
|
|
+
|
|
|
+ const uploads = [
|
|
|
+ ...(coverFile.value instanceof File
|
|
|
+ ? [() => uploadEventCover(created.id, coverFile.value)]
|
|
|
+ : []),
|
|
|
+ ...pendingMedia.value.map((pending) => () => uploadEventMedia(created.id, pending.file)),
|
|
|
+ ];
|
|
|
+
|
|
|
+ uploadDone.value = 0;
|
|
|
+ uploadTotal.value = uploads.length;
|
|
|
+ uploadingMedia.value = uploads.length > 0;
|
|
|
+
|
|
|
+ try {
|
|
|
+ for (const upload of uploads) {
|
|
|
+ await upload();
|
|
|
+ uploadDone.value += 1;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ uploadingMedia.value = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return created;
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ pendingMedia.value.forEach((pending) => URL.revokeObjectURL(pending.preview));
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.media-thumb {
|
|
|
+ position: relative;
|
|
|
+ height: 140px;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid rgba(102, 29, 117, 0.2);
|
|
|
+ background: #f5f0f7;
|
|
|
+}
|
|
|
+
|
|
|
+.media-thumb-img {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: cover;
|
|
|
+}
|
|
|
+
|
|
|
+.media-thumb-delete {
|
|
|
+ position: absolute;
|
|
|
+ top: 4px;
|
|
|
+ right: 4px;
|
|
|
+}
|
|
|
+</style>
|