Эх сурвалжийг харах

refactor: altera grafico de channels para barras horizontais

Gustavo Mantovani 1 сар өмнө
parent
commit
0f41d46388

+ 1 - 0
src/composables/useAuth.js

@@ -34,6 +34,7 @@ export const useAuth = () => {
 
 
         await permissionStore().fetchScopes();
         await permissionStore().fetchScopes();
       }
       }
+
       return response;
       return response;
     } catch (error) {
     } catch (error) {
       return Promise.reject(error);
       return Promise.reject(error);

+ 25 - 3
src/pages/LoginPage.vue

@@ -1,8 +1,18 @@
 <template>
 <template>
-  <q-page padding class="login-page">
-    <q-card flat class="login-card q-pa-lg bg-surface">
+  <q-page
+    class="login-page"
+    padding
+  >
+    <q-card
+      class="login-card q-pa-lg bg-surface"
+      flat
+    >
       <div class="text-center">
       <div class="text-center">
-        <q-img src="images/kizzo_logo.svg" style="max-width: 220px" />
+        <q-img
+          src="images/kizzo_logo.svg"
+          style="max-width: 220px"
+        />
+
         <div
         <div
           class="text-weight-regular"
           class="text-weight-regular"
           style="font-size: 20px; letter-spacing: 0.15px"
           style="font-size: 20px; letter-spacing: 0.15px"
@@ -62,12 +72,15 @@
 import { onMounted, ref } from "vue";
 import { onMounted, ref } from "vue";
 import { useAuth } from "src/composables/useAuth";
 import { useAuth } from "src/composables/useAuth";
 import { useInputRules } from "src/composables/useInputRules";
 import { useInputRules } from "src/composables/useInputRules";
+import { useQuasar } from "quasar";
 import { useRouter } from "vue-router";
 import { useRouter } from "vue-router";
 
 
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 
 
 const router = useRouter();
 const router = useRouter();
 
 
+const $q = useQuasar();
+
 const { inputRules } = useInputRules();
 const { inputRules } = useInputRules();
 
 
 const email = ref("");
 const email = ref("");
@@ -82,6 +95,8 @@ const submitLogin = async () => {
     const validate = await loginForm.value.validate();
     const validate = await loginForm.value.validate();
 
 
     if (!validate) {
     if (!validate) {
+      submitting.value = false;
+
       return;
       return;
     }
     }
 
 
@@ -92,6 +107,13 @@ const submitLogin = async () => {
     router.push({ name: "DashboardPage" });
     router.push({ name: "DashboardPage" });
   } catch (error) {
   } catch (error) {
     submitting.value = false;
     submitting.value = false;
+    
+    $q.notify({
+      type: "negative",
+      message: error?.response?.data?.message || "Falha no login. Verifique suas credenciais.",
+      position: "top-right",
+      timeout: 5000,
+    });
   }
   }
 };
 };
 
 

+ 211 - 145
src/pages/dashboard/DashboardPage.vue

@@ -85,10 +85,10 @@
         </div>
         </div>
 
 
         <div class="dashboard-panels">
         <div class="dashboard-panels">
-          <DashboardAvailabilityPanel
-            :is-all-properties-selected="isAllPropertiesSelected"
+          <DashboardHorizontalBarPanel
+            title="Disponibilidade do Período"
             :items="availabilityItems"
             :items="availabilityItems"
-            :total-capacity-days="totalCapacityDays"
+            :total-label="availabilityTotalLabel"
           />
           />
 
 
           <DashboardRevenuePanel
           <DashboardRevenuePanel
@@ -96,7 +96,11 @@
             :chart-options="revenueChartOptions"
             :chart-options="revenueChartOptions"
           />
           />
 
 
-          <DashboardChannelsPanel :channels="channels" />
+          <DashboardHorizontalBarPanel
+            title="Canais de Aquisição"
+            :items="channelsBarItems"
+            :total-label="channelsTotalLabel"
+          />
 
 
           <q-card flat class="panel-card panel-card--soft">
           <q-card flat class="panel-card panel-card--soft">
             <div class="panel-title">Repasses por Unidade</div>
             <div class="panel-title">Repasses por Unidade</div>
@@ -148,10 +152,9 @@ import {
 } from "src/helpers/utils";
 } from "src/helpers/utils";
 
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DashboardAvailabilityPanel from "./components/DashboardAvailabilityPanel.vue";
 import DashboardFiltersBar from "./components/DashboardFiltersBar.vue";
 import DashboardFiltersBar from "./components/DashboardFiltersBar.vue";
+import DashboardHorizontalBarPanel from "./components/DashboardHorizontalBarPanel.vue";
 import DashboardMetricCard from "./components/DashboardMetricCard.vue";
 import DashboardMetricCard from "./components/DashboardMetricCard.vue";
-import DashboardChannelsPanel from "./components/DashboardChannelsPanel.vue";
 import DashboardPayoutTable from "./components/DashboardPayoutTable.vue";
 import DashboardPayoutTable from "./components/DashboardPayoutTable.vue";
 import DashboardRevenuePanel from "./components/DashboardRevenuePanel.vue";
 import DashboardRevenuePanel from "./components/DashboardRevenuePanel.vue";
 
 
@@ -221,12 +224,12 @@ const monthLabels = [
   "Dezembro",
   "Dezembro",
 ];
 ];
 
 
+//
+
 const availableReferences = computed(
 const availableReferences = computed(
   () => dashboard.value?.filters?.available_references ?? [],
   () => dashboard.value?.filters?.available_references ?? [],
 );
 );
 
 
-const channels = computed(() => dashboard.value?.channels ?? []);
-
 const selectedFilters = computed(
 const selectedFilters = computed(
   () => dashboard.value?.filters?.selected ?? {},
   () => dashboard.value?.filters?.selected ?? {},
 );
 );
@@ -247,12 +250,14 @@ const propertyOptions = computed(() => {
 
 
 const hasProperties = computed(() => propertyOptions.value.length > 1);
 const hasProperties = computed(() => propertyOptions.value.length > 1);
 
 
-//
-
 const selectedPropertyId = computed(() => {
 const selectedPropertyId = computed(() => {
   return selectedPropertyOption.value?.id ?? null;
   return selectedPropertyOption.value?.id ?? null;
 });
 });
 
 
+const isAllPropertiesSelected = computed(
+  () => selectedPropertyId.value === null,
+);
+
 const canExportReport = computed(
 const canExportReport = computed(
   () =>
   () =>
     hasProperties.value &&
     hasProperties.value &&
@@ -260,10 +265,6 @@ const canExportReport = computed(
     selectedPropertyId.value !== null,
     selectedPropertyId.value !== null,
 );
 );
 
 
-const isAllPropertiesSelected = computed(
-  () => selectedPropertyId.value === null,
-);
-
 //
 //
 
 
 const payoutRows = computed(() => dashboard.value?.properties_breakdown ?? []);
 const payoutRows = computed(() => dashboard.value?.properties_breakdown ?? []);
@@ -318,17 +319,6 @@ const monthOptions = computed(() => {
 
 
 //
 //
 
 
-const selectedReferenceLabel = computed(() => {
-  if (!selectedMonth.value || !selectedYear.value) {
-    return "Mês selecionado";
-  }
-
-  const monthLabel =
-    monthLabels[selectedMonth.value - 1] ?? String(selectedMonth.value);
-
-  return `${monthLabel} ${selectedYear.value}`;
-});
-
 const allMetricCards = computed(() =>
 const allMetricCards = computed(() =>
   buildDashboardMetricCards(summary.value, {
   buildDashboardMetricCards(summary.value, {
     isAllPropertiesSelected: isAllPropertiesSelected.value,
     isAllPropertiesSelected: isAllPropertiesSelected.value,
@@ -345,8 +335,41 @@ const secondRowCards = computed(() =>
   allMetricCards.value.slice(7, 14),
   allMetricCards.value.slice(7, 14),
 );
 );
 
 
+const selectedReferenceLabel = computed(() => {
+  if (!selectedMonth.value || !selectedYear.value) {
+    return "Mês selecionado";
+  }
+
+  const monthLabel =
+    monthLabels[selectedMonth.value - 1] ?? String(selectedMonth.value);
+
+  return `${monthLabel} ${selectedYear.value}`;
+});
+
+//
+
+const availabilityTotalLabel = computed(() => {
+  if (isAllPropertiesSelected.value) {
+    return "Total: 100% do período consolidado";
+  }
+
+  const total = Number(summary.value.days_in_month ?? 0);
+
+  return `Total: ${formatInteger(total)} dias no período`;
+});
+
+const channelsTotalLabel = computed(() => {
+  const total = Number(summary.value.reservations_count ?? 0);
+
+  return `Total: ${formatInteger(total)} reservas`;
+});
+
+//
+
 const availabilityItems = computed(() => {
 const availabilityItems = computed(() => {
-  const total = totalCapacityDays.value;
+  const total =
+    Number(summary.value.days_in_month ?? 0) *
+    Number(summary.value.properties_count ?? 1);
 
 
   const items = [
   const items = [
     {
     {
@@ -366,21 +389,84 @@ const availabilityItems = computed(() => {
     },
     },
   ];
   ];
 
 
-  return items.map((item) => ({
-    ...item,
-    valueLabel: isAllPropertiesSelected.value
-      ? formatPercent(total > 0 ? (item.value * 100) / total : 0, 1)
-      : `${formatInteger(item.value)} dias`,
+  return items.map((item, index) => ({
+    key: `${item.label}-${index}`,
+
+    label: item.label,
+
+    valueLabel: `${formatInteger(item.value)} dias (${formatPercent(
+      total > 0 ? (item.value * 100) / total : 0,
+      1,
+    )})`,
+
+    percentage:
+      total > 0
+        ? Number(((item.value * 100) / total).toFixed(2))
+        : 0,
 
 
-    percentage: total > 0 ? Number(((item.value * 100) / total).toFixed(2)) : 0,
+    color: item.color,
   }));
   }));
 });
 });
 
 
-//
+const channelColorsMap = {
+  "API booking.com": "#6b93cf",
+  "API airbnb": "#ff8f93",
+  "API Decolar": "#c4b5fd",
+  "Sem canal": "#d3d3d3",
+  "Raniery Kohler": "#7BB2AB",
+};
+
+const ranieryFallbackPalette = [
+  "#7bb2ab",
+  "#8bbdb7",
+  "#9bc8c3",
+  "#abd3cf",
+  "#bbdedb",
+  "#cbe9e7",
+];
+
+const channelsBarItems = computed(() => {
+  const channels = dashboard.value?.channels ?? [];
+
+  let fallbackIndex = 0;
+
+  return channels.map((item, index) => {
+    const fallbackColor =
+      ranieryFallbackPalette[
+        fallbackIndex++ % ranieryFallbackPalette.length
+      ];
+
+    return {
+      key: `${item.channel}-${index}`,
+
+      label: item.channel,
+
+      valueLabel: `${formatInteger(
+        item.reservations_count,
+      )} reservas (${formatPercent(item.share_percentage, 1)})`,
+
+      percentage: Number(item.share_percentage ?? 0),
+
+      color: channelColorsMap[item.channel] ?? fallbackColor,
+    };
+  });
+});
+
+const shortMonthLabel = (month, year) => {
+  if (!month || !year) {
+    return "-";
+  }
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    month: "short",
+  })
+    .format(new Date(year, month - 1, 1))
+    .replace(".", "");
+};
 
 
 const revenueChartData = computed(() => {
 const revenueChartData = computed(() => {
-  const series = revenueSeries.value.length
-    ? revenueSeries.value
+  const seriesRaw = revenueSeries.value.length
+    ? [...revenueSeries.value]
     : [
     : [
         {
         {
           label: shortMonthLabel(selectedMonth.value, selectedYear.value),
           label: shortMonthLabel(selectedMonth.value, selectedYear.value),
@@ -388,20 +474,20 @@ const revenueChartData = computed(() => {
         },
         },
       ];
       ];
 
 
+  seriesRaw.pop();
+
   return {
   return {
-    labels: series.map((item) => item.label),
+    labels: seriesRaw.map((item) => item.label),
     datasets: [
     datasets: [
       {
       {
         label: "Faturamento",
         label: "Faturamento",
-        data: series.map((item) => item.value),
+        data: seriesRaw.map((item) => item.value),
         borderColor: "#399FE7",
         borderColor: "#399FE7",
         borderWidth: 2,
         borderWidth: 2,
         fill: false,
         fill: false,
         tension: 0.35,
         tension: 0.35,
-
         pointBackgroundColor: "#399FE7",
         pointBackgroundColor: "#399FE7",
         pointBorderColor: "#399FE7",
         pointBorderColor: "#399FE7",
-
         pointRadius: 3,
         pointRadius: 3,
         pointHoverRadius: 4,
         pointHoverRadius: 4,
       },
       },
@@ -452,20 +538,6 @@ const revenueChartOptions = computed(() => ({
 
 
 //
 //
 
 
-const shortMonthLabel = (month, year) => {
-  if (!month || !year) {
-    return "-";
-  }
-
-  return new Intl.DateTimeFormat("pt-BR", {
-    month: "short",
-  })
-    .format(new Date(year, month - 1, 1))
-    .replace(".", "");
-};
-
-//
-
 const buildDashboardParams = ({ year, month, propertyId, format } = {}) => {
 const buildDashboardParams = ({ year, month, propertyId, format } = {}) => {
   const params = {};
   const params = {};
 
 
@@ -501,8 +573,6 @@ const buildFallbackReportFilename = (format) => {
   return `owner_dashboard_report_${year}${month}_${propertySegment}.${format}`;
   return `owner_dashboard_report_${year}${month}_${propertySegment}.${format}`;
 };
 };
 
 
-//
-
 const downloadBlob = (blob, filename) => {
 const downloadBlob = (blob, filename) => {
   const url = window.URL.createObjectURL(blob);
   const url = window.URL.createObjectURL(blob);
   const link = document.createElement("a");
   const link = document.createElement("a");
@@ -519,6 +589,52 @@ const downloadBlob = (blob, filename) => {
   window.URL.revokeObjectURL(url);
   window.URL.revokeObjectURL(url);
 };
 };
 
 
+const exportDashboardReport = async (format) => {
+  if (!canExportReport.value) {
+    return;
+  }
+
+  exporting.value = true;
+
+  try {
+    const response = await downloadOwnerDashboardReport(
+      buildDashboardParams({
+        year: selectedYear.value,
+        month: selectedMonth.value,
+        propertyId: selectedPropertyId.value,
+        format,
+      }),
+    );
+
+    downloadBlob(response.data, resolveReportFilename(response, format));
+  } catch (error) {
+    const blobMessage = await extractBlobErrorMessage(error?.response?.data);
+
+    $q.notify({
+      type: "negative",
+      message:
+        blobMessage ?? "Não foi possível exportar o relatório do dashboard.",
+    });
+  } finally {
+    exporting.value = false;
+  }
+};
+
+const extractBlobErrorMessage = async (blob) => {
+  if (!(blob instanceof Blob)) {
+    return null;
+  }
+
+  try {
+    const text = await blob.text();
+    const parsed = JSON.parse(text);
+
+    return parsed?.message ?? null;
+  } catch {
+    return null;
+  }
+};
+
 const findPropertyOptionById = (propertyId) => {
 const findPropertyOptionById = (propertyId) => {
   return (
   return (
     propertyOptions.value.find((option) => option.id === propertyId) ??
     propertyOptions.value.find((option) => option.id === propertyId) ??
@@ -553,54 +669,47 @@ const resolveReportFilename = (response, format) => {
 
 
 //
 //
 
 
-const extractBlobErrorMessage = async (blob) => {
-  if (!(blob instanceof Blob)) {
-    return null;
-  }
+const fetchDashboard = async ({ year, month, propertyId } = {}) => {
+  const requestId = ++dashboardRequestId.value;
 
 
-  try {
-    const text = await blob.text();
-    const parsed = JSON.parse(text);
+  loading.value = true;
 
 
-    return parsed?.message ?? null;
-  } catch {
-    return null;
-  }
-};
+  try {
+    const payload = await getOwnerDashboard(
+      buildDashboardParams({ year, month, propertyId }),
+    );
 
 
-const exportDashboardReport = async (format) => {
-  if (!canExportReport.value) {
-    return;
-  }
+    if (requestId !== dashboardRequestId.value) {
+      return;
+    }
 
 
-  exporting.value = true;
+    dashboard.value = payload;
 
 
-  try {
-    const response = await downloadOwnerDashboardReport(
-      buildDashboardParams({
-        year: selectedYear.value,
-        month: selectedMonth.value,
-        propertyId: selectedPropertyId.value,
-        format,
-      }),
+    selectedPropertyOption.value = findPropertyOptionById(
+      payload.filters.selected?.property_id ?? null,
     );
     );
+    selectedMonth.value = payload.filters.selected?.reference_month ?? null;
+    selectedYear.value = payload.filters.selected?.reference_year ?? null;
 
 
-    downloadBlob(response.data, resolveReportFilename(response, format));
+    await fetchRevenueHistory(payload);
   } catch (error) {
   } catch (error) {
-    const blobMessage = await extractBlobErrorMessage(error?.response?.data);
+    if (requestId !== dashboardRequestId.value) {
+      return;
+    }
 
 
     $q.notify({
     $q.notify({
       type: "negative",
       type: "negative",
       message:
       message:
-        blobMessage ?? "Não foi possível exportar o relatório do dashboard.",
+        error?.response?.data?.message ??
+        "Não foi possível carregar o dashboard do proprietário.",
     });
     });
   } finally {
   } finally {
-    exporting.value = false;
+    if (requestId === dashboardRequestId.value) {
+      loading.value = false;
+    }
   }
   }
 };
 };
 
 
-//
-
 const fetchRevenueHistory = async (payload) => {
 const fetchRevenueHistory = async (payload) => {
   const requestId = ++historyRequestId.value;
   const requestId = ++historyRequestId.value;
 
 
@@ -646,65 +755,6 @@ const fetchRevenueHistory = async (payload) => {
   }
   }
 };
 };
 
 
-const fetchDashboard = async ({ year, month, propertyId } = {}) => {
-  const requestId = ++dashboardRequestId.value;
-
-  loading.value = true;
-
-  try {
-    const payload = await getOwnerDashboard(
-      buildDashboardParams({ year, month, propertyId }),
-    );
-
-    if (requestId !== dashboardRequestId.value) {
-      return;
-    }
-
-    dashboard.value = payload;
-
-    selectedPropertyOption.value = findPropertyOptionById(
-      payload.filters.selected?.property_id ?? null,
-    );
-    selectedMonth.value = payload.filters.selected?.reference_month ?? null;
-    selectedYear.value = payload.filters.selected?.reference_year ?? null;
-
-    await fetchRevenueHistory(payload);
-  } catch (error) {
-    if (requestId !== dashboardRequestId.value) {
-      return;
-    }
-
-    $q.notify({
-      type: "negative",
-      message:
-        error?.response?.data?.message ??
-        "Não foi possível carregar o dashboard do proprietário.",
-    });
-  } finally {
-    if (requestId === dashboardRequestId.value) {
-      loading.value = false;
-    }
-  }
-};
-
-//
-
-const handlePropertyChange = async (propertyOption) => {
-  const nextPropertyId = propertyOption?.id ?? null;
-
-  const currentPropertyId = selectedFilters.value.property_id ?? null;
-
-  if (nextPropertyId === currentPropertyId) {
-    return;
-  }
-
-  await fetchDashboard({
-    year: selectedYear.value,
-    month: selectedMonth.value,
-    propertyId: nextPropertyId,
-  });
-};
-
 //
 //
 
 
 const handleMonthChange = async (month) => {
 const handleMonthChange = async (month) => {
@@ -740,6 +790,22 @@ const handleYearChange = async (year) => {
   });
   });
 };
 };
 
 
+const handlePropertyChange = async (propertyOption) => {
+  const nextPropertyId = propertyOption?.id ?? null;
+
+  const currentPropertyId = selectedFilters.value.property_id ?? null;
+
+  if (nextPropertyId === currentPropertyId) {
+    return;
+  }
+
+  await fetchDashboard({
+    year: selectedYear.value,
+    month: selectedMonth.value,
+    propertyId: nextPropertyId,
+  });
+};
+
 //
 //
 
 
 onMounted(async () => {
 onMounted(async () => {

+ 260 - 0
src/pages/dashboard/components/DashboardHorizontalBarPanel.vue

@@ -0,0 +1,260 @@
+<template>
+  <q-card class="panel-card panel-card--soft" flat>
+    <div class="panel-title">{{ title }}</div>
+
+    <div class="availability-list">
+      <div
+        v-for="item in decoratedItems"
+        :key="item.label"
+        class="availability-item"
+      >
+        <div class="availability-track">
+          <div
+            class="availability-bar"
+            :style="{
+              backgroundColor: item.color,
+            }"
+          >
+            <div class="availability-track-meta">
+              <div class="availability-bar-label-wrap">
+                <span
+                  class="availability-bar-value-dot"
+                  :style="{
+                    backgroundColor: item.textColor,
+                  }"
+                />
+
+                <span class="availability-bar-label">
+                  {{ item.label }}
+                </span>
+              </div>
+
+              <span class="availability-bar-value-wrap">
+                <span
+                  class="availability-bar-value"
+                >
+                  {{ item.valueLabel }}
+                </span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="availability-total-track">
+      <div
+        v-for="item in items"
+        :key="`${item.label}-total`"
+        :style="{
+          width: `${item.percentage}%`,
+          backgroundColor: item.color,
+        }"
+        class="availability-total-segment"
+      />
+    </div>
+
+    <div v-if="totalLabel" class="availability-total">
+      {{ totalLabel }}
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+
+  items: {
+    type: Array,
+    default: () => [],
+  },
+
+  totalLabel: {
+    type: String,
+    default: null,
+  },
+});
+
+const clampChannel = (value) => Math.max(0, Math.min(255, value));
+
+const darkenColor = (hexColor, amount = 0.42) => {
+  const hex = String(hexColor ?? "").replace("#", "");
+
+  if (hex.length !== 6) {
+    return "#173235";
+  }
+
+  const channels = [0, 2, 4].map((start) =>
+    Number.parseInt(hex.slice(start, start + 2), 16),
+  );
+
+  const darkened = channels.map((channel) =>
+    clampChannel(Math.round(channel * (1 - amount))),
+  );
+
+  return `#${darkened
+    .map((channel) => channel.toString(16).padStart(2, "0"))
+    .join("")}`;
+};
+
+const decoratedItems = computed(() =>
+  props.items.map((item) => ({
+    ...item,
+    textColor: darkenColor(item.color),
+  })),
+);
+</script>
+
+<style scoped lang="scss">
+.panel-card {
+  padding: 18px;
+  border-radius: 14px;
+  background: #ffffff;
+  border: 1px solid #d9e3e7;
+  min-height: 300px;
+  vertical-align: middle;
+}
+
+.panel-card--soft {
+  background: #f0f3f5;
+}
+
+.panel-title {
+  margin-bottom: 16px;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
+}
+
+.availability-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.availability-item {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.availability-track {
+  position: relative;
+  width: 100%;
+  min-height: 42px;
+  overflow: hidden;
+  background: #dde5e8;
+  vertical-align: middle;
+}
+
+.availability-bar {
+  width: 100%;
+  min-height: 42px;
+  vertical-align: middle;
+}
+
+.availability-track-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  width: 100%;
+  min-width: 0;
+  min-height: 42px;
+  padding: 0 14px;
+  position: relative;
+  z-index: 1;
+  color: #173235;
+  font-size: 13px;
+  font-weight: 700;
+  white-space: nowrap;
+  vertical-align: middle;
+}
+
+.availability-bar-label {
+  text-align: left;
+  vertical-align: middle;
+}
+
+.availability-bar-label-wrap,
+.availability-bar-value-wrap {
+  display: inline-flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 8px;
+  min-width: 0;
+  vertical-align: middle;
+}
+
+.availability-bar-value {
+  text-align: right;
+  overflow-wrap: anywhere;
+  vertical-align: middle;
+}
+
+.availability-bar-value-dot {
+  display: inline-flex;
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  vertical-align: middle;
+}
+
+.availability-total-track {
+  display: flex;
+  width: 100%;
+  height: 18px;
+  margin-top: 18px;
+  overflow: hidden;
+  background: #dde5e8;
+  vertical-align: middle;
+}
+
+.availability-total-segment {
+  height: 100%;
+  min-width: 0;
+  vertical-align: middle;
+}
+
+.availability-total {
+  margin-top: 18px;
+  color: #657177;
+  font-size: 14px;
+  text-align: center;
+}
+
+@media (max-width: 640px) {
+  .availability-list {
+    gap: 12px;
+  }
+
+  .availability-track,
+  .availability-bar {
+    min-height: 48px;
+  }
+
+  .availability-track-meta {
+    flex-direction: column;
+    align-items: flex-start;
+    justify-content: center;
+    gap: 8px;
+    min-height: 48px;
+    padding: 0 12px;
+    font-size: 12px;
+    white-space: normal;
+  }
+
+  .availability-bar-value-wrap {
+    width: 100%;
+  }
+
+  .availability-bar-value {
+    text-align: left;
+  }
+}
+</style>