|
|
@@ -0,0 +1,409 @@
|
|
|
+<template>
|
|
|
+ <div>
|
|
|
+ <DefaultHeaderPage />
|
|
|
+
|
|
|
+ <div class="q-pa-md column q-gutter-lg">
|
|
|
+
|
|
|
+ <q-card flat bordered>
|
|
|
+ <q-card-section class="q-pb-none">
|
|
|
+ <div class="text-h6 text-weight-medium">
|
|
|
+ {{ $t("company_settings.section.hero") }}
|
|
|
+ </div>
|
|
|
+ </q-card-section>
|
|
|
+ <q-card-section>
|
|
|
+ <q-form ref="heroFormRef" @submit="saveSettings">
|
|
|
+ <div class="row q-col-gutter-md">
|
|
|
+
|
|
|
+ <div class="col-12">
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.hero.background_image") }}</div>
|
|
|
+ <div class="row items-center q-col-gutter-sm">
|
|
|
+ <div class="col-auto">
|
|
|
+ <q-btn
|
|
|
+ outline
|
|
|
+ color="primary"
|
|
|
+ icon="mdi-upload"
|
|
|
+ :label="$t('company_settings.hero.upload_image')"
|
|
|
+ :loading="uploadingImage"
|
|
|
+ @click="triggerImageUpload"
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ ref="imageInputRef"
|
|
|
+ type="file"
|
|
|
+ accept="image/*"
|
|
|
+ class="hidden"
|
|
|
+ @change="onImageSelected"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div v-if="settings.hero_background_url" class="col-auto">
|
|
|
+ <q-btn
|
|
|
+ flat
|
|
|
+ color="negative"
|
|
|
+ icon="mdi-delete"
|
|
|
+ :label="$t('company_settings.hero.remove_image')"
|
|
|
+ :loading="removingImage"
|
|
|
+ @click="onRemoveImage"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div v-if="settings.hero_background_url" class="col-12">
|
|
|
+ <img
|
|
|
+ :src="settings.hero_background_url"
|
|
|
+ style="max-height: 160px; border-radius: 8px; object-fit: cover"
|
|
|
+ class="q-mt-xs"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.hero_title"
|
|
|
+ v-model:error="validationErrors.hero_title"
|
|
|
+ :label="$t('company_settings.hero.title')"
|
|
|
+ class="col-12"
|
|
|
+ />
|
|
|
+
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.hero_subtitle"
|
|
|
+ v-model:error="validationErrors.hero_subtitle"
|
|
|
+ :label="$t('company_settings.hero.subtitle')"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ class="col-12"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="col-12">
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.section.stats") }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-for="n in 3" :key="n">
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form[`stat${n}_value`]"
|
|
|
+ v-model:error="validationErrors[`stat${n}_value`]"
|
|
|
+ :label="$t('company_settings.stat.value', { n })"
|
|
|
+ class="col-md-3 col-6"
|
|
|
+ />
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form[`stat${n}_label`]"
|
|
|
+ v-model:error="validationErrors[`stat${n}_label`]"
|
|
|
+ :label="$t('company_settings.stat.label', { n })"
|
|
|
+ class="col-md-3 col-6"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="col-12">
|
|
|
+ <div class="text-subtitle2 q-mb-sm">{{ $t("company_settings.section.contacts") }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.contact_email"
|
|
|
+ v-model:error="validationErrors.contact_email"
|
|
|
+ :label="$t('company_settings.contact.email')"
|
|
|
+ type="email"
|
|
|
+ class="col-md-4 col-12"
|
|
|
+ />
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.contact_phone"
|
|
|
+ v-model:error="validationErrors.contact_phone"
|
|
|
+ :label="$t('company_settings.contact.phone')"
|
|
|
+ class="col-md-4 col-12"
|
|
|
+ />
|
|
|
+ <DefaultInput
|
|
|
+ v-model="form.contact_location"
|
|
|
+ v-model:error="validationErrors.contact_location"
|
|
|
+ :label="$t('company_settings.contact.location')"
|
|
|
+ :placeholder="$t('company_settings.contact.location_placeholder')"
|
|
|
+ class="col-md-4 col-12"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="col-12 flex justify-end">
|
|
|
+ <q-btn
|
|
|
+ color="primary"
|
|
|
+ icon="mdi-content-save"
|
|
|
+ :label="$t('common.actions.save')"
|
|
|
+ type="submit"
|
|
|
+ :loading="savingSettings"
|
|
|
+ :disable="!hasUpdatedFields"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </q-form>
|
|
|
+ </q-card-section>
|
|
|
+ </q-card>
|
|
|
+
|
|
|
+ <q-card flat bordered>
|
|
|
+ <q-card-section class="q-pb-none">
|
|
|
+ <div class="row items-center">
|
|
|
+ <div class="text-h6 text-weight-medium col">
|
|
|
+ {{ $t("company_settings.section.benefits") }}
|
|
|
+ </div>
|
|
|
+ <q-btn
|
|
|
+ color="primary"
|
|
|
+ icon="mdi-plus"
|
|
|
+ :label="$t('common.actions.add')"
|
|
|
+ padding="8px 8px"
|
|
|
+ @click="onAddBenefit"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </q-card-section>
|
|
|
+ <q-card-section>
|
|
|
+ <div v-if="benefitsLoading" class="flex flex-center q-py-lg">
|
|
|
+ <q-spinner color="primary" size="md" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <div class="row items-center q-px-sm q-py-xs text-caption text-grey-7 text-weight-medium q-mb-xs">
|
|
|
+ <div style="width: 28px" />
|
|
|
+ <div style="width: 52px">{{ $t("company_settings.benefit.icon") }}</div>
|
|
|
+ <div class="col">{{ $t("company_settings.benefit.title") }}</div>
|
|
|
+ <div class="col-5">{{ $t("company_settings.benefit.description") }}</div>
|
|
|
+ <div style="width: 84px">{{ $t("common.terms.actions") }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <draggable
|
|
|
+ v-model="benefits"
|
|
|
+ item-key="id"
|
|
|
+ handle=".drag-handle"
|
|
|
+ :animation="180"
|
|
|
+ ghost-class="benefit-row--ghost"
|
|
|
+ @end="onDragEnd"
|
|
|
+ >
|
|
|
+ <template #item="{ element }">
|
|
|
+ <div class="benefit-row row items-center q-px-sm q-mb-xs rounded-borders">
|
|
|
+ <div class="drag-handle q-mr-xs">
|
|
|
+ <q-icon name="mdi-drag-vertical" size="sm" color="grey-4"/>
|
|
|
+ </div>
|
|
|
+ <div style="width: 52px">
|
|
|
+ <q-icon :name="element.icon" size="sm" color="primary" />
|
|
|
+ </div>
|
|
|
+ <div class="col text-body2">{{ element.title }}</div>
|
|
|
+ <div class="col-5 text-caption text-grey-7">{{ element.description }}</div>
|
|
|
+ <div style="width: 84px" class="row no-wrap q-gutter-xs">
|
|
|
+ <q-btn
|
|
|
+ outline
|
|
|
+ style="width: 36px"
|
|
|
+ @click="onEditBenefit(element)"
|
|
|
+ >
|
|
|
+ <q-icon name="mdi-file-edit-outline" />
|
|
|
+ </q-btn>
|
|
|
+ <q-btn
|
|
|
+ outline
|
|
|
+ style="width: 36px"
|
|
|
+ @click="onDeleteBenefit(element.id)"
|
|
|
+ >
|
|
|
+ <q-icon name="mdi-trash-can-outline" />
|
|
|
+ </q-btn>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </draggable>
|
|
|
+
|
|
|
+ <div v-if="!benefits.length" class="text-center text-grey q-py-md text-body2">
|
|
|
+ {{ $t("http.errors.no_records_found") }}
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </q-card-section>
|
|
|
+ </q-card>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, useTemplateRef, onMounted, defineAsyncComponent } from "vue";
|
|
|
+import { useQuasar } from "quasar";
|
|
|
+import { useI18n } from "vue-i18n";
|
|
|
+import draggable from "vuedraggable";
|
|
|
+import {
|
|
|
+ getCompanySettings,
|
|
|
+ updateCompanySettings,
|
|
|
+ uploadCompanyHeroImage,
|
|
|
+ removeCompanyHeroImage,
|
|
|
+ getCompanyBenefits,
|
|
|
+ deleteCompanyBenefit,
|
|
|
+ reorderCompanyBenefits,
|
|
|
+} from "src/api/companySettings";
|
|
|
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
|
|
|
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
|
|
|
+
|
|
|
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
|
|
|
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
|
|
|
+
|
|
|
+const AddEditBenefitDialog = defineAsyncComponent(
|
|
|
+ () => import("./components/AddEditBenefitDialog.vue"),
|
|
|
+);
|
|
|
+
|
|
|
+const $q = useQuasar();
|
|
|
+const { t } = useI18n();
|
|
|
+const heroFormRef = useTemplateRef("heroFormRef");
|
|
|
+const imageInputRef = useTemplateRef("imageInputRef");
|
|
|
+
|
|
|
+const settings = ref({});
|
|
|
+const uploadingImage = ref(false);
|
|
|
+const removingImage = ref(false);
|
|
|
+const benefits = ref([]);
|
|
|
+const benefitsLoading = ref(true);
|
|
|
+
|
|
|
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
|
|
|
+ hero_title: "",
|
|
|
+ hero_subtitle: "",
|
|
|
+ stat1_value: "",
|
|
|
+ stat1_label: "",
|
|
|
+ stat2_value: "",
|
|
|
+ stat2_label: "",
|
|
|
+ stat3_value: "",
|
|
|
+ stat3_label: "",
|
|
|
+ contact_email: "",
|
|
|
+ contact_phone: "",
|
|
|
+ contact_location: "",
|
|
|
+});
|
|
|
+
|
|
|
+const { loading: savingSettings, validationErrors, execute: submitForm } = useSubmitHandler({
|
|
|
+ formRef: heroFormRef,
|
|
|
+});
|
|
|
+
|
|
|
+
|
|
|
+const loadSettings = async () => {
|
|
|
+ try {
|
|
|
+ const data = await getCompanySettings();
|
|
|
+ if (data) {
|
|
|
+ settings.value = data;
|
|
|
+ Object.keys(form).forEach((key) => {
|
|
|
+ if (key in data) form[key] = data[key] ?? "";
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const saveSettings = async () => {
|
|
|
+ await submitForm(async () => {
|
|
|
+ const updated = await updateCompanySettings(getUpdatedFields.value);
|
|
|
+ settings.value = { ...settings.value, ...updated };
|
|
|
+ $q.notify({ type: "positive", message: t("common.ui.messages.saved") });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const triggerImageUpload = () => {
|
|
|
+ imageInputRef.value.click();
|
|
|
+};
|
|
|
+
|
|
|
+const onImageSelected = async (event) => {
|
|
|
+ const file = event.target.files?.[0];
|
|
|
+ if (!file) return;
|
|
|
+ uploadingImage.value = true;
|
|
|
+ try {
|
|
|
+ const updated = await uploadCompanyHeroImage(file);
|
|
|
+ settings.value = { ...settings.value, ...updated };
|
|
|
+ $q.notify({ type: "positive", message: t("common.ui.messages.saved") });
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ } finally {
|
|
|
+ uploadingImage.value = false;
|
|
|
+ event.target.value = "";
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onRemoveImage = async () => {
|
|
|
+ removingImage.value = true;
|
|
|
+ try {
|
|
|
+ const updated = await removeCompanyHeroImage();
|
|
|
+ settings.value = { ...settings.value, ...updated };
|
|
|
+ $q.notify({ type: "positive", message: t("common.ui.messages.saved") });
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ } finally {
|
|
|
+ removingImage.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const loadBenefits = async () => {
|
|
|
+ benefitsLoading.value = true;
|
|
|
+ try {
|
|
|
+ benefits.value = await getCompanyBenefits() ?? [];
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ } finally {
|
|
|
+ benefitsLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onDragEnd = async () => {
|
|
|
+ const reordered = benefits.value.map((b, index) => ({ id: b.id, order: index + 1 }));
|
|
|
+ try {
|
|
|
+ await reorderCompanyBenefits(reordered);
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ await loadBenefits();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const onAddBenefit = () => {
|
|
|
+ $q.dialog({
|
|
|
+ component: AddEditBenefitDialog,
|
|
|
+ componentProps: {
|
|
|
+ title: () => `${t("common.actions.add")} ${t("company_settings.benefit.title_singular")}`,
|
|
|
+ },
|
|
|
+ }).onOk(() => loadBenefits());
|
|
|
+};
|
|
|
+
|
|
|
+const onEditBenefit = (row) => {
|
|
|
+ $q.dialog({
|
|
|
+ component: AddEditBenefitDialog,
|
|
|
+ componentProps: {
|
|
|
+ benefit: row,
|
|
|
+ title: () => `${t("common.actions.edit")} ${t("company_settings.benefit.title_singular")}`,
|
|
|
+ },
|
|
|
+ }).onOk(() => loadBenefits());
|
|
|
+};
|
|
|
+
|
|
|
+const onDeleteBenefit = (id) => {
|
|
|
+ $q.dialog({
|
|
|
+ title: t("common.ui.messages.confirm_action"),
|
|
|
+ message: t("common.ui.messages.are_you_sure_delete"),
|
|
|
+ ok: { color: "negative", label: t("common.actions.delete") },
|
|
|
+ cancel: { color: "primary", outline: true, label: t("common.actions.cancel") },
|
|
|
+ }).onOk(async () => {
|
|
|
+ try {
|
|
|
+ await deleteCompanyBenefit(id);
|
|
|
+ await loadBenefits();
|
|
|
+ } catch {
|
|
|
+ $q.notify({ type: "negative", message: t("common.ui.messages.error") });
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadSettings();
|
|
|
+ loadBenefits();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.benefit-row {
|
|
|
+ border: 1px solid rgba(0, 0, 0, 0.12);
|
|
|
+ min-height: 52px;
|
|
|
+ transition: background-color 0.15s;
|
|
|
+}
|
|
|
+
|
|
|
+.benefit-row:hover {
|
|
|
+ background-color: rgba(0, 0, 0, 0.03);
|
|
|
+}
|
|
|
+
|
|
|
+.benefit-row--ghost {
|
|
|
+ opacity: 0.4;
|
|
|
+ background: var(--q-primary);
|
|
|
+}
|
|
|
+
|
|
|
+.drag-handle {
|
|
|
+ touch-action: none;
|
|
|
+ cursor: grab;
|
|
|
+}
|
|
|
+
|
|
|
+.drag-handle:active {
|
|
|
+ cursor: grabbing;
|
|
|
+}
|
|
|
+
|
|
|
+.drag-handle:hover .q-icon {
|
|
|
+ color: var(--q-primary) !important;
|
|
|
+}
|
|
|
+</style>
|