Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,162 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
bannerMessage: {
type: String,
default: '',
},
hrefLink: {
type: String,
default: '',
},
hrefLinkText: {
type: String,
default: '',
},
hasActionButton: {
type: Boolean,
default: false,
},
actionButtonVariant: {
type: String,
default: 'faded',
},
actionButtonLabel: {
type: String,
default: '',
},
actionButtonIcon: {
type: String,
default: 'i-lucide-arrow-right',
},
colorScheme: {
type: String,
default: '',
},
hasCloseButton: {
type: Boolean,
default: false,
},
},
emits: ['primaryAction', 'close'],
computed: {
bannerClasses() {
const classList = [this.colorScheme];
if (this.hasActionButton || this.hasCloseButton) {
classList.push('has-button');
}
return classList;
},
// TODO - Remove this method when we standardize
// the button color and variant names
getButtonColor() {
const colorMap = {
primary: 'blue',
secondary: 'blue',
alert: 'ruby',
warning: 'amber',
};
return colorMap[this.colorScheme] || 'blue';
},
},
methods: {
onClick(e) {
this.$emit('primaryAction', e);
},
onClickClose(e) {
this.$emit('close', e);
},
},
};
</script>
<template>
<div
class="flex items-center justify-center h-12 gap-4 px-4 py-3 text-xs text-white banner dark:text-white woot-banner"
:class="bannerClasses"
>
<span class="banner-message">
{{ bannerMessage }}
<a
v-if="hrefLink"
:href="hrefLink"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ hrefLinkText }}
</a>
</span>
<div class="actions">
<NextButton
v-if="hasActionButton"
xs
:icon="actionButtonIcon"
:variant="actionButtonVariant"
:color="getButtonColor"
:label="actionButtonLabel"
@click="onClick"
/>
<NextButton
v-if="hasCloseButton"
xs
icon="i-lucide-circle-x"
:color="getButtonColor"
:label="$t('GENERAL_SETTINGS.DISMISS')"
@click="onClickClose"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.banner {
&.primary {
@apply bg-n-brand;
}
&.secondary {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
a {
@apply text-n-slate-12;
}
}
&.alert {
@apply bg-n-ruby-3 text-n-ruby-12;
a {
@apply text-n-ruby-12;
}
}
&.warning {
@apply bg-n-amber-5 text-n-amber-12;
a {
@apply text-n-amber-12;
}
}
&.gray {
@apply text-n-gray-10 dark:text-n-gray-10;
}
a {
@apply ml-1 underline text-n-amber-12 text-xs;
}
.banner-message {
@apply flex items-center;
}
.actions {
@apply flex gap-1 right-3;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<script setup>
import {
computed,
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
});
const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position
let left = x;
let top = y;
// Boundary checks
const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH - PADDING;
return {
left: Math.max(PADDING, left),
top: Math.max(PADDING, top),
};
};
const position = computed(() => {
if (!menuRef.value) return { top: `${props.y}px`, left: `${props.x}px` };
const { left, top } = calculatePosition(
props.x,
props.y,
menuWidth.value,
menuHeight.value,
windowWidth.value,
windowHeight.value
);
return {
top: `${top}px`,
left: `${left}px`,
};
});
onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus());
});
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script>
<template>
<TeleportWithDirection to="body">
<div
ref="menuRef"
class="fixed outline-none z-[9999] cursor-pointer"
:style="position"
tabindex="0"
@blur="handleClose"
>
<slot />
</div>
</TeleportWithDirection>
</template>

View File

@@ -0,0 +1,455 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper';
import {
isValid,
startOfMonth,
subDays,
startOfDay,
endOfDay,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth,
setYear,
getWeek,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const emit = defineEmits(['dateRangeChanged']);
const { t } = useI18n();
const dateRange = defineModel('dateRange', {
type: Array,
default: undefined,
});
const rangeType = defineModel('rangeType', {
type: String,
default: undefined,
});
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
// Use dates from v-model if provided, otherwise default to last 7 days
const selectedStartDate = ref(
dateRange.value?.[0]
? startOfDay(dateRange.value[0])
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
);
const selectedEndDate = ref(
dateRange.value?.[1]
? endOfDay(dateRange.value[1])
: endOfDay(currentDate.value)
);
// Calendar month positioning (left and right calendars)
// These control which months are displayed in the dual calendar view
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
);
const selectingEndDate = ref(false);
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
const hoveredEndDate = ref(null);
const monthOffset = ref(0);
const showMonthNavigation = computed(() =>
isNavigableRange(selectedRange.value)
);
const canNavigateNext = computed(() => {
if (!isNavigableRange(selectedRange.value)) return false;
// Compare selected start to the current period's start to determine if we're in the past
const currentRange = getActiveDateRange(
selectedRange.value,
currentDate.value
);
return selectedStartDate.value < currentRange.start;
});
const navigationLabel = computed(() => {
const range = selectedRange.value;
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
return new Intl.DateTimeFormat(navigator.language, {
month: 'long',
}).format(selectedStartDate.value);
}
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
const currentWeekRange = getActiveDateRange(range, currentDate.value);
const isCurrentWeek =
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
if (isCurrentWeek) return null;
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
}
return null;
});
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
// Watcher 1: Sync v-model props from parent component
// Handles: URL params, parent component updates, rangeType changes
watch(
[rangeType, dateRange],
([newRangeType, newDateRange]) => {
if (newRangeType && newRangeType !== selectedRange.value) {
selectedRange.value = newRangeType;
monthOffset.value = 0;
// If rangeType changes without dateRange, recompute dates from the range
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
if (activeDates) {
selectedStartDate.value = startOfDay(activeDates.start);
selectedEndDate.value = endOfDay(activeDates.end);
}
}
}
// When parent provides new dateRange (e.g., from URL params)
// Skip if navigating with arrows — offset controls dates in that case
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
selectedStartDate.value = startOfDay(newDateRange[0]);
selectedEndDate.value = endOfDay(newDateRange[1]);
// Update calendar to show the months of the new date range
startCurrentDate.value = startOfMonth(newDateRange[0]);
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
? startOfMonth(addMonths(newDateRange[1], 1))
: startOfMonth(newDateRange[1]);
// Recalculate offset so arrow navigation is relative to restored range
// TODO: When offset resolves to 0 (current period), the end date may be
// stale if the URL was saved on a previous day. "This month" / "This week"
// should show up-to-today dates for the current period. For now, the stale
// end date is shown until the user clicks an arrow or re-selects the range.
if (isNavigableRange(selectedRange.value)) {
const current = getActiveDateRange(
selectedRange.value,
currentDate.value
);
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
monthOffset.value = differenceInCalendarWeeks(
newDateRange[0],
current.start,
{ weekStartsOn: 1 }
);
} else {
monthOffset.value = differenceInCalendarMonths(
newDateRange[0],
current.start
);
}
}
}
},
{ immediate: true }
);
// Watcher 2: Keep manual input fields in sync with selected dates
// Updates the input field values when dates change programmatically
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
manualStartDate.value = isValid(newStart)
? newStart
: selectedStartDate.value;
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
},
{ immediate: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
// Position calendar to show the months of the selected range
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
};
const navigateMonth = direction => {
monthOffset.value += direction === 'prev' ? -1 : 1;
if (monthOffset.value > 0) monthOffset.value = 0;
const { start, end } = getRangeAtOffset(
selectedRange.value,
monthOffset.value,
currentDate.value
);
selectedStartDate.value = start;
selectedEndDate.value = end;
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
emit('dateRangeChanged', [start, end, selectedRange.value]);
};
const moveCalendar = (calendar, direction, period = MONTH) => {
const { start, end } = moveCalendarDate(
calendar,
startCurrentDate.value,
endCurrentDate.value,
direction,
period
);
// Prevent calendar months from overlapping
const monthDiff = differenceInCalendarMonths(end, start);
if (monthDiff === 0) {
// If they would be the same month, adjust the other calendar
if (calendar === START_CALENDAR) {
endCurrentDate.value = addMonths(start, 1);
startCurrentDate.value = start;
} else {
startCurrentDate.value = subMonths(end, 1);
endCurrentDate.value = end;
}
} else {
startCurrentDate.value = start;
endCurrentDate.value = end;
}
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
selectingEndDate.value = true;
} else {
selectedEndDate.value = day;
selectingEndDate.value = false;
}
};
const setViewMode = (calendar, mode) => {
selectedRange.value = CUSTOM_RANGE;
calendarViews.value[calendar] = mode;
};
const openCalendar = (index, calendarType, period = MONTH) => {
const current =
calendarType === START_CALENDAR
? startCurrentDate.value
: endCurrentDate.value;
const newDate =
period === MONTH
? setMonth(startOfMonth(current), index)
: setYear(current, index);
if (calendarType === START_CALENDAR) {
startCurrentDate.value = newDate;
} else {
endCurrentDate.value = newDate;
}
setViewMode(calendarType, period === MONTH ? WEEK : MONTH);
};
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = startOfMonth(newDate);
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = startOfMonth(newDate);
}
selectingEndDate.value = false;
};
const handleManualInputError = message => {
useAlert(message);
};
const resetDatePicker = () => {
// Calculate Last 7 days from today
const startDate = startOfDay(subDays(currentDate.value, 6));
const endDate = endOfDay(currentDate.value);
selectedStartDate.value = startDate;
selectedEndDate.value = endDate;
// Position calendar to show the months of Last 7 days
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
startCurrentDate.value = startOfMonth(startDate);
endCurrentDate.value = isSameMonth(startDate, endDate)
? startOfMonth(addMonths(startDate, 1))
: startOfMonth(endDate);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
monthOffset.value = 0;
calendarViews.value = { start: WEEK, end: WEEK };
};
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [
selectedStartDate.value,
selectedEndDate.value,
selectedRange.value,
]);
}
};
// Called when picker opens - positions calendar to show selected date range
// Fixes issue where calendar showed wrong months when loaded from URL params
const initializeCalendarMonths = () => {
if (selectedStartDate.value && selectedEndDate.value) {
startCurrentDate.value = startOfMonth(selectedStartDate.value);
endCurrentDate.value = isSameMonth(
selectedStartDate.value,
selectedEndDate.value
)
? startOfMonth(addMonths(selectedEndDate.value, 1))
: startOfMonth(selectedEndDate.value);
}
};
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value;
if (showDatePicker.value) initializeCalendarMonths();
};
const closeDatePicker = () => {
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
};
</script>
<template>
<div class="relative flex-shrink-0 font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
:show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
/>
<div
v-if="showDatePicker"
v-on-clickaway="closeDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
<div
v-for="calendar in [START_CALENDAR, END_CALENDAR]"
:key="`${calendar}-calendar`"
class="flex flex-col items-center"
>
<CalendarDateInput
:calendar-type="calendar"
:date-value="
calendar === START_CALENDAR ? manualStartDate : manualEndDate
"
:compare-date="
calendar === START_CALENDAR ? manualEndDate : manualStartDate
"
:is-disabled="selectedRange !== CUSTOM_RANGE"
@update="
calendar === START_CALENDAR
? (manualStartDate = $event)
: (manualEndDate = $event)
"
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-n-strong">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-n-strong'
"
>
<CalendarYear
v-if="calendarViews[calendar] === YEAR"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
<CalendarWeek
v-else-if="calendarViews[calendar] === WEEK"
:calendar-type="calendar"
:current-date="currentDate"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
</div>
</div>
</div>
</div>
<CalendarFooter @change="emitDateRange" @clear="resetDatePicker" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
calendarType: {
type: String,
default: 'start',
},
firstButtonLabel: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
viewMode: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev', 'next', 'setView']);
const { YEAR } = CALENDAR_PERIODS;
const onClickPrev = type => {
emit('prev', type);
};
const onClickNext = type => {
emit('next', type);
};
const onClickSetView = (type, mode) => {
emit('setView', type, mode);
};
</script>
<template>
<div class="flex items-start justify-between w-full h-9">
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click.stop="onClickPrev(calendarType)"
/>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click.stop="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click.stop="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<NextButton
slate
ghost
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click.stop="onClickNext(calendarType)"
/>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed } from 'vue';
import { parse, isValid, isAfter, isBefore } from 'date-fns';
import {
getIntlDateFormatForLocale,
CALENDAR_TYPES,
} from '../helpers/DatePickerHelper';
const props = defineProps({
calendarType: {
type: String,
default: '',
},
dateValue: Date,
compareDate: Date,
isDisabled: Boolean,
});
const emit = defineEmits(['update', 'validate', 'error']);
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
const localDateValue = computed({
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
set: newValue => {
const format = getIntlDateFormatForLocale();
const parsedDate = parse(newValue, format, new Date());
if (isValid(parsedDate)) {
emit('update', parsedDate);
}
},
});
const validateDate = () => {
if (!isValid(props.dateValue)) {
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
return;
}
const { calendarType, compareDate, dateValue } = props;
const isStartCalendar = calendarType === START_CALENDAR;
const isEndCalendar = calendarType === END_CALENDAR;
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
emit('error', 'Start date must be before the end date.');
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
emit('error', 'End date must be after the start date.');
} else {
emit('validate', dateValue);
}
};
</script>
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-n-slate-12">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
}}
</span>
<input
v-model="localDateValue"
type="text"
class="!text-sm !mb-0 disabled:!outline-n-strong"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
/>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
import { dateRanges } from '../helpers/DatePickerHelper';
defineProps({
selectedRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['setRange']);
const setDateRange = range => {
emit('setRange', range);
};
</script>
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<template v-for="range in dateRanges" :key="range.label">
<div v-if="range.separator" class="w-full border-t border-n-strong" />
<button
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['clear', 'change']);
const onClickClear = () => {
emit('clear');
};
const onClickApply = () => {
emit('change');
};
</script>
<template>
<div class="h-[56px] flex justify-between gap-2 px-2 py-3 items-center">
<NextButton
slate
ghost
sm
:label="$t('DATE_PICKER.CLEAR_BUTTON')"
@click="onClickClear"
/>
<NextButton
sm
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
/>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
import {
yearName,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const emit = defineEmits(['selectMonth', 'prev', 'next', 'setView']);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH, YEAR } = CALENDAR_PERIODS;
const months = Array.from({ length: 12 }, (_, index) =>
format(setMonth(startOfMonth(new Date()), index), 'MMM')
);
const activeMonthIndex = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getMonth(date);
});
const setViewMode = (type, mode) => {
emit('setView', type, mode);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const selectMonth = index => {
emit('selectMonth', index);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="YEAR"
:calendar-type="calendarType"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
MONTH
)
"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[92px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-n-brand text-white hover:bg-n-blue-10':
index === activeMonthIndex,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click.stop="selectMonth(index)"
>
{{ month }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup>
import {
monthName,
yearName,
getWeeksForMonth,
isToday,
dayIsInRange,
isCurrentMonth,
isLastDayOfMonth,
isHoveringDayInRange,
isHoveringNextDayInRange,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarWeekLabel from './CalendarWeekLabel.vue';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
currentDate: Date,
startCurrentDate: Date,
endCurrentDate: Date,
selectedStartDate: Date,
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
});
const emit = defineEmits([
'updateHoveredEndDate',
'selectDate',
'prev',
'next',
'setView',
]);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const emitHoveredEndDate = day => {
emit('updateHoveredEndDate', day);
};
const emitSelectDate = day => {
emit('selectDate', day);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const setViewMode = (type, mode) => {
emit('setView', type, mode);
};
const weeks = calendarType => {
return getWeeksForMonth(
calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isSelectedStartOrEndDate = day => {
return (
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
);
};
const isInRange = day => {
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
};
const isInCurrentMonth = day => {
return isCurrentMonth(
day,
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isHoveringInRange = day => {
return isHoveringDayInRange(
day,
props.selectedStartDate,
props.selectingEndDate,
props.hoveredEndDate
);
};
const isNextDayInRange = day => {
return isHoveringNextDayInRange(
day,
props.selectedStartDate,
props.selectedEndDate,
props.hoveredEndDate
);
};
const dayClasses = day => ({
'text-n-slate-10 pointer-events-none': !isInCurrentMonth(day),
'text-n-slate-12 hover:text-n-slate-12 hover:bg-n-blue-6 dark:hover:bg-n-blue-7':
isInCurrentMonth(day),
'bg-n-brand text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-n-blue-4 dark:bg-n-blue-5':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-11':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="MONTH"
:calendar-type="calendarType"
:first-button-label="
monthName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
@prev="onClickPrev"
@next="onClickNext"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
v-for="week in weeks(calendarType)"
:key="week[0].getTime()"
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
>
<div
v-for="day in week"
:key="day.getTime()"
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
:class="dayClasses(day)"
@mouseenter="emitHoveredEndDate(day)"
@mouseleave="emitHoveredEndDate(null)"
@click="emitSelectDate(day)"
>
{{ day.getDate() }}
<span
v-if="
(isInRange(day) || isHoveringInRange(day)) &&
isNextDayInRange(day) &&
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-n-blue-4 dark:bg-n-blue-5 -z-10"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import { calendarWeeks } from '../helpers/DatePickerHelper';
</script>
<template>
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
<div
v-for="day in calendarWeeks"
:key="day.id"
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
>
{{ day.label }}
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import { getYear, addYears, subYears } from 'date-fns';
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const emit = defineEmits(['selectYear']);
const { START_CALENDAR } = CALENDAR_TYPES;
const calculateStartYear = date => {
const year = getYear(date);
return year - (year % 10); // Align with the beginning of a decade
};
const startYear = ref(
calculateStartYear(
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
)
);
const years = computed(() =>
Array.from({ length: 10 }, (_, i) => startYear.value + i)
);
const firstYear = computed(() => years.value[0]);
const lastYear = computed(() => years.value[years.value.length - 1]);
const activeYear = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getYear(date);
});
const onClickPrev = () => {
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const onClickNext = () => {
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const selectYear = year => {
emit('selectYear', year);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:calendar-type="calendarType"
:button-label="`${firstYear} - ${lastYear}`"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-n-slate-12 w-[144px] h-10 rounded-lg py-2.5 px-2"
:class="{
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}"
@click.stop="selectYear(year)"
>
{{ year }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedStartDate: Date,
selectedEndDate: Date,
selectedRange: {
type: String,
default: '',
},
showMonthNavigation: {
type: Boolean,
default: false,
},
canNavigateNext: {
type: Boolean,
default: false,
},
navigationLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['open', 'navigateMonth']);
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
const endDate = props.selectedEndDate;
if (!isValid(startDate) || !isValid(endDate)) {
return 'Select a date range';
}
const crossesYears = !isSameYear(startDate, endDate);
// Always show years when crossing year boundaries
if (crossesYears) {
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
}
// For same year, always show the year for clarity
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
});
const activeDateRange = computed(
() => dateRanges.find(range => range.value === props.selectedRange).label
);
const openDatePicker = () => {
emit('open');
};
</script>
<template>
<div class="inline-flex items-center gap-1">
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
@click="openDatePicker"
>
<Icon
icon="i-lucide-calendar-range"
class="text-n-slate-11 size-3.5 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ navigationLabel || $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11 truncate">
{{ formatDateRange }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-12 size-4 flex-shrink-0"
/>
</button>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="emit('navigateMonth', 'prev')"
/>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
:disabled="!canNavigateNext"
@click="emit('navigateMonth', 'next')"
/>
</div>
</template>

View File

@@ -0,0 +1,290 @@
import {
startOfDay,
subDays,
endOfDay,
subMonths,
addMonths,
subYears,
addYears,
startOfMonth,
isSameMonth,
format,
startOfWeek,
endOfWeek,
addWeeks,
addDays,
eachDayOfInterval,
endOfMonth,
isSameDay,
isWithinInterval,
} from 'date-fns';
// Constants for calendar and date ranges
export const calendarWeeks = [
{ id: 1, label: 'M' },
{ id: 2, label: 'T' },
{ id: 3, label: 'W' },
{ id: 4, label: 'T' },
{ id: 5, label: 'F' },
{ id: 6, label: 'S' },
{ id: 7, label: 'S' },
];
export const dateRanges = [
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_7_DAYS', value: 'last7days' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_30_DAYS', value: 'last30days' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
value: 'thisWeek',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
value: 'monthToDate',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
value: 'custom',
separator: true,
},
];
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last7days',
LAST_30_DAYS: 'last30days',
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
THIS_WEEK: 'thisWeek',
MONTH_TO_DATE: 'monthToDate',
CUSTOM_RANGE: 'custom',
};
export const CALENDAR_TYPES = {
START_CALENDAR: 'start',
END_CALENDAR: 'end',
};
export const CALENDAR_PERIODS = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
export const getIntlDateFormatForLocale = () => {
const year = 2222;
const month = 12;
const day = 15;
const date = new Date(year, month - 1, day);
const formattedDate = new Intl.DateTimeFormat(navigator.language).format(
date
);
return formattedDate
.replace(`${year}`, 'yyyy')
.replace(`${month}`, 'MM')
.replace(`${day}`, 'dd');
};
// Utility functions for calendar operations
export const chunk = (array, size) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, index) =>
array.slice(index * size, index * size + size)
);
export const getWeeksForMonth = (date, weekStartsOn = 1) => {
const startOfTheMonth = startOfMonth(date);
const startOfTheFirstWeek = startOfWeek(startOfTheMonth, { weekStartsOn });
const endOfTheLastWeek = addDays(startOfTheFirstWeek, 41); // Covering six weeks to fill the calendar
return chunk(
eachDayOfInterval({ start: startOfTheFirstWeek, end: endOfTheLastWeek }),
7
);
};
export const moveCalendarDate = (
calendar,
startCurrentDate,
endCurrentDate,
direction,
period
) => {
const adjustFunctions = {
month: { prev: subMonths, next: addMonths },
year: { prev: subYears, next: addYears },
};
const adjust = adjustFunctions[period][direction];
if (calendar === 'start') {
const newStart = adjust(startCurrentDate, 1);
return { start: newStart, end: endCurrentDate };
}
const newEnd = adjust(endCurrentDate, 1);
return { start: startCurrentDate, end: newEnd };
};
// Date comparison functions
export const isToday = (currentDate, date) =>
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
export const isCurrentMonth = (day, referenceDate) =>
isSameMonth(day, referenceDate);
export const isLastDayOfMonth = day => {
const lastDay = endOfMonth(day);
return day.getDate() === lastDay.getDate();
};
export const dayIsInRange = (date, startDate, endDate) => {
if (!startDate || !endDate) {
return false;
}
// Normalize dates to ignore time differences
let startOfDayStart = startOfDay(startDate);
let startOfDayEnd = startOfDay(endDate);
// Swap if start is greater than end
if (startOfDayStart > startOfDayEnd) {
[startOfDayStart, startOfDayEnd] = [startOfDayEnd, startOfDayStart];
}
// Check if the date is within the interval or is the same as the start date
return (
isSameDay(date, startOfDayStart) ||
isWithinInterval(date, {
start: startOfDayStart,
end: startOfDayEnd,
})
);
};
// Handling hovering states in date range pickers
export const isHoveringDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (endDate && hoveredEndDate && startDate <= hoveredEndDate) {
// Ensure the start date is not after the hovered end date
return isWithinInterval(day, { start: startDate, end: hoveredEndDate });
}
return false;
};
export const isHoveringNextDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (startDate && !endDate && hoveredEndDate) {
// If a start date is selected, and we're hovering (but haven't clicked an end date yet)
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: hoveredEndDate });
}
if (startDate && endDate) {
// Normal range checking between selected start and end dates
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: endDate });
}
return false;
};
// Helper func to determine active date ranges based on user selection
export const getActiveDateRange = (range, currentDate) => {
const ranges = {
last7days: () => ({
start: startOfDay(subDays(currentDate, 6)),
end: endOfDay(currentDate),
}),
last30days: () => ({
start: startOfDay(subDays(currentDate, 29)),
end: endOfDay(currentDate),
}),
last3months: () => ({
start: startOfDay(subMonths(currentDate, 3)),
end: endOfDay(currentDate),
}),
last6months: () => ({
start: startOfDay(subMonths(currentDate, 6)),
end: endOfDay(currentDate),
}),
lastYear: () => ({
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
thisWeek: () => ({
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
end: endOfDay(currentDate),
}),
monthToDate: () => ({
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
return (
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
export const isNavigableRange = rangeType =>
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
const WEEK_START = 1; // Monday
const getWeekRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
end: endOfDay(currentDate),
};
}
const targetWeek = addWeeks(currentDate, offset);
return {
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
};
};
const getMonthRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
};
}
const targetMonth = addMonths(currentDate, offset);
return {
start: startOfDay(startOfMonth(targetMonth)),
end: endOfDay(endOfMonth(targetMonth)),
};
};
export const getRangeAtOffset = (
rangeType,
offset,
currentDate = new Date()
) => {
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
return getWeekRangeAtOffset(offset, currentDate);
}
return getMonthRangeAtOffset(offset, currentDate);
};

View File

@@ -0,0 +1,309 @@
import {
monthName,
yearName,
getIntlDateFormatForLocale,
getWeeksForMonth,
isToday,
isCurrentMonth,
isLastDayOfMonth,
dayIsInRange,
getActiveDateRange,
isHoveringDayInRange,
isHoveringNextDayInRange,
moveCalendarDate,
chunk,
} from '../DatePickerHelper';
describe('Date formatting functions', () => {
const testDate = new Date(2020, 4, 15); // May 15, 2020
beforeEach(() => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns the correct month name from a date', () => {
expect(monthName(testDate)).toBe('May');
});
it('returns the correct year from a date', () => {
expect(yearName(testDate)).toBe('2020');
});
it('returns the correct date format for the current locale en-US', () => {
const expected = 'MM/dd/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
it('returns the correct date format for the current locale en-IN', () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-IN');
const expected = 'dd/MM/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
});
describe('chunk', () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
it('correctly chunks an array into smaller arrays of given size', () => {
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
expect(chunk(array, 3)).toEqual(expected);
});
it('handles arrays that do not divide evenly by the chunk size', () => {
const expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9]];
expect(chunk(array, 2)).toEqual(expected);
});
});
describe('getWeeksForMonth', () => {
it('returns the correct weeks array for a month starting on Monday', () => {
const date = new Date(2020, 3, 1); // April 2020
const weeks = getWeeksForMonth(date, 1);
expect(weeks.length).toBe(6);
expect(weeks[0][0]).toEqual(new Date(2020, 2, 30)); // Check if first day of the first week is correct
});
});
describe('moveCalendarDate', () => {
it('handles the year transition when moving the start date backward by one month from January', () => {
const startDate = new Date(2021, 0, 1);
const endDate = new Date(2021, 0, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'prev',
'month'
);
const expectedStartDate = new Date(2020, 11, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the start date forward by one month from January', () => {
const startDate = new Date(2020, 0, 1);
const endDate = new Date(2020, 1, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'next',
'month'
);
const expectedStartDate = new Date(2020, 1, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the end date forward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'next', 'month');
const expectedEndDate = new Date(2022, 0, 31);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
it('handles the year transition when moving the end date backward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'prev', 'month');
const expectedEndDate = new Date(2021, 10, 30);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
});
describe('isToday', () => {
it('returns true if the dates are the same', () => {
const today = new Date();
const alsoToday = new Date(today);
expect(isToday(today, alsoToday)).toBeTruthy();
});
it('returns false if the dates are not the same', () => {
const today = new Date();
const notToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
expect(isToday(today, notToday)).toBeFalsy();
});
});
describe('isCurrentMonth', () => {
const referenceDate = new Date(2020, 6, 15); // July 15, 2020
it('returns true if the day is in the same month as the reference date', () => {
const testDay = new Date(2020, 6, 1);
expect(isCurrentMonth(testDay, referenceDate)).toBeTruthy();
});
it('returns false if the day is not in the same month as the reference date', () => {
const testDay = new Date(2020, 5, 30);
expect(isCurrentMonth(testDay, referenceDate)).toBeFalsy();
});
});
describe('isLastDayOfMonth', () => {
it('returns true if the day is the last day of the month', () => {
const testDay = new Date(2020, 6, 31); // July 31, 2020
expect(isLastDayOfMonth(testDay)).toBeTruthy();
});
it('returns false if the day is not the last day of the month', () => {
const testDay = new Date(2020, 6, 30); // July 30, 2020
expect(isLastDayOfMonth(testDay)).toBeFalsy();
});
});
describe('dayIsInRange', () => {
it('returns true if the date is within the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 15);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns true if the date is the same as the start date', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 10);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns false if the date is outside the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 9);
expect(dayIsInRange(testDate, start, end)).toBeFalsy();
});
});
describe('isHoveringDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const endDate = new Date(2020, 6, 20);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 12);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the day is outside the hovered date range', () => {
const testDay = new Date(2020, 6, 16);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeFalsy();
});
});
describe('isHoveringNextDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the next day after the given day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 14);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the next day is outside the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 15);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeFalsy();
});
});
describe('getActiveDateRange', () => {
const currentDate = new Date(2020, 5, 15, 12, 0); // May 15, 2020, at noon
beforeEach(() => {
// Mocking the current date to ensure consistency in tests
vi.useFakeTimers().setSystemTime(currentDate.getTime());
});
afterEach(() => {
vi.useRealTimers();
});
it('returns the correct range for "last7days"', () => {
const range = getActiveDateRange('last7days', new Date());
const expectedStart = new Date(2020, 5, 9);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last30days"', () => {
const range = getActiveDateRange('last30days', new Date());
const expectedStart = new Date(2020, 4, 17);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last3months"', () => {
const range = getActiveDateRange('last3months', new Date());
const expectedStart = new Date(2020, 2, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last6months"', () => {
const range = getActiveDateRange('last6months', new Date());
const expectedStart = new Date(2019, 11, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "lastYear"', () => {
const range = getActiveDateRange('lastYear', new Date());
const expectedStart = new Date(2019, 5, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "custom date range"', () => {
const range = getActiveDateRange('custom', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
it('handles an unknown range label gracefully', () => {
const range = getActiveDateRange('unknown', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
});

View File

@@ -0,0 +1,41 @@
<script>
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Array,
default: () => [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
this.$emit('change', value);
},
},
};
</script>
<template>
<div class="date-picker">
<DatePicker
range
confirm
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
@change="handleChange"
/>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script>
import addDays from 'date-fns/addDays';
import DatePicker from 'vue-datepicker-next';
export default {
components: { DatePicker },
props: {
confirmText: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
value: {
type: Date,
default: [],
},
},
emits: ['change'],
methods: {
handleChange(value) {
this.$emit('change', value);
},
disableBeforeToday(date) {
const yesterdayDate = addDays(new Date(), -1);
return date < yesterdayDate;
},
},
};
</script>
<template>
<div class="date-picker">
<DatePicker
type="datetime"
confirm
:clearable="false"
:editable="false"
:confirm-text="confirmText"
:placeholder="placeholder"
:value="value"
:disabled-date="disableBeforeToday"
@change="handleChange"
/>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
buttonText: {
type: String,
default: '',
},
trailingIcon: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
});
</script>
<template>
<Button
ghost
slate
sm
class="relative"
no-animation
:icon="icon"
:trailing-icon="trailingIcon"
>
<span class="min-w-0 truncate">{{ buttonText }}</span>
<slot name="dropdown" />
</Button>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
{{ message }}
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup>
import { ref, computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { picoSearch } from '@scmmishra/pico-search';
import ListItemButton from './DropdownListItemButton.vue';
import DropdownSearch from './DropdownSearch.vue';
import DropdownEmptyState from './DropdownEmptyState.vue';
import DropdownLoadingState from './DropdownLoadingState.vue';
const props = defineProps({
listItems: {
type: Array,
default: () => [],
},
enableSearch: {
type: Boolean,
default: false,
},
inputPlaceholder: {
type: String,
default: '',
},
activeFilterId: {
type: [String, Number],
default: null,
},
showClearFilter: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
loadingPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['onSearch', 'select', 'removeFilter']);
const searchTerm = ref('');
const debouncedEmit = debounce(value => {
emit('onSearch', value);
}, 300);
const onSearch = value => {
searchTerm.value = value;
debouncedEmit(value);
};
const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems;
return picoSearch(props.listItems, searchTerm.value, ['name']);
});
const isDropdownListEmpty = computed(() => {
return !filteredListItems.value.length;
});
const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
const shouldShowLoadingState = computed(() => {
return (
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
);
});
const shouldShowEmptyState = computed(() => {
return !props.isLoading && isDropdownListEmpty.value;
});
</script>
<template>
<div
class="absolute z-20 w-40 bg-n-solid-2 border-0 outline outline-1 outline-n-weak shadow rounded-xl max-h-[400px]"
@click.stop
>
<slot name="search">
<DropdownSearch
v-if="enableSearch"
v-model="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@update:model-value="onSearch"
@remove="$emit('removeFilter')"
/>
</slot>
<slot name="listItem">
<DropdownLoadingState
v-if="shouldShowLoadingState"
:message="loadingPlaceholder"
/>
<DropdownEmptyState
v-else-if="shouldShowEmptyState"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<ListItemButton
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
:button-text="item.name"
:icon="item.icon"
:icon-color="item.iconColor"
@click.stop.prevent="emit('select', item)"
/>
</slot>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
defineProps({
buttonText: {
type: String,
default: '',
},
isActive: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:enabled:bg-n-alpha-2"
>
<div class="inline-flex items-center gap-3 overflow-hidden">
<fluent-icon
v-if="icon"
:icon="icon"
size="18"
:style="{ color: iconColor }"
/>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ buttonText }}
</span>
<fluent-icon
v-if="isActive"
icon="checkmark"
size="18"
class="flex-shrink-0 text-n-slate-12"
/>
</div>
<slot name="dropdown" />
</button>
</template>

View File

@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex items-center justify-center h-10 text-sm text-n-slate-11">
{{ message }}
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { defineEmits, defineModel } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
inputPlaceholder: {
type: String,
default: '',
},
showClearFilter: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove']);
const value = defineModel({
type: String,
default: '',
});
</script>
<template>
<div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-n-solid-2 dark:bg-n-solid-2 z-10 gap-2 px-3 border-b rounded-t-xl border-n-weak"
>
<div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon
icon="search"
size="16"
class="text-n-slate-11 flex-shrink-0"
/>
<input
v-model="value"
:placeholder="inputPlaceholder"
type="search"
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
/>
</div>
<!-- Clear filter button -->
<NextButton
v-if="!modelValue && showClearFilter"
faded
xs
class="flex-shrink-0"
:label="$t('REPORT.FILTER_ACTIONS.CLEAR_FILTER')"
@click="emit('remove')"
/>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-n-slate-11 absolute"
/>
<div
class="bg-n-background w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-n-weak rounded-lg shadow-md"
>
<p class="text-n-slate-12 mb-0 text-xs">
{{ message }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,213 @@
<script>
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
props: {
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
href: {
type: String,
default: '',
},
bgColor: {
type: String,
default: '',
},
small: {
type: Boolean,
default: false,
},
showClose: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: '',
},
variant: {
type: String,
default: '',
},
},
emits: ['remove'],
computed: {
textColor() {
if (this.variant === 'smooth') return '';
if (this.variant === 'dashed') return '';
return this.color || getContrastingTextColor(this.bgColor);
},
labelClass() {
return `label ${this.colorScheme} ${this.variant} ${
this.small ? 'small' : ''
}`;
},
labelStyle() {
if (this.bgColor) {
return {
background: this.bgColor,
color: this.textColor,
border: `1px solid ${this.bgColor}`,
};
}
return {};
},
anchorStyle() {
if (this.bgColor) {
return { color: this.textColor };
}
return {};
},
},
methods: {
onClick() {
this.$emit('remove', this.title);
},
},
};
</script>
<template>
<div
class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
:class="labelClass"
:style="labelStyle"
:title="description"
>
<span v-if="icon" class="label-action--button">
<fluent-icon :icon="icon" size="12" class="label--icon cursor-pointer" />
</span>
<span
v-if="['smooth', 'dashed'].includes(variant) && title && !icon"
:style="{ background: color }"
class="label-color-dot flex-shrink-0"
/>
<span v-if="!href" class="whitespace-nowrap text-ellipsis overflow-hidden">
{{ title }}
</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<button
v-if="showClose"
class="label-close--button p-0"
:style="{ color: textColor }"
@click="onClick"
>
<fluent-icon icon="dismiss" size="12" class="close--icon" />
</button>
</div>
</template>
<style scoped lang="scss">
.label {
@apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-n-slate-3 text-n-slate-12 border border-solid border-n-strong h-6;
&.small {
@apply text-xs py-0.5 px-1 leading-tight h-5;
}
&.small .label--icon,
&.small .close--icon {
@apply text-[0.5rem];
}
a {
@apply text-xs;
&:hover {
@apply underline;
}
}
/* Color Schemes */
&.primary {
@apply bg-n-blue-5 text-n-blue-12 border border-solid border-n-blue-7;
a {
@apply text-n-blue-12;
}
.label-color-dot {
@apply bg-n-blue-9;
}
}
&.secondary {
@apply bg-n-slate-5 text-n-slate-12 border border-solid border-n-slate-7;
a {
@apply text-n-slate-12;
}
.label-color-dot {
@apply bg-n-slate-9;
}
}
&.success {
@apply bg-n-teal-5 text-n-teal-12 border border-solid border-n-teal-7;
a {
@apply text-n-teal-12;
}
.label-color-dot {
@apply bg-n-teal-9;
}
}
&.alert {
@apply bg-n-ruby-5 text-n-ruby-12 border border-solid border-n-ruby-7;
a {
@apply text-n-ruby-12;
}
.label-color-dot {
@apply bg-n-ruby-9;
}
}
&.warning {
@apply bg-n-amber-5 text-n-amber-12 border border-solid border-n-amber-7;
a {
@apply text-n-amber-12;
}
.label-color-dot {
@apply bg-n-amber-9;
}
}
&.smooth {
@apply bg-transparent text-n-slate-11 dark:text-n-slate-12 border border-solid border-n-strong;
}
&.dashed {
@apply bg-transparent text-n-slate-11 dark:text-n-slate-12 border border-dashed border-n-strong;
}
}
.label-close--button {
@apply text-n-slate-11 -mb-0.5 rounded-sm cursor-pointer flex items-center justify-center hover:bg-n-slate-3;
svg {
@apply text-n-slate-11;
}
}
.label-action--button {
@apply flex mr-1;
}
.label-color-dot {
@apply inline-block w-3 h-3 rounded-sm shadow-sm;
}
.label.small .label-color-dot {
@apply w-2 h-2 rounded-sm shadow-sm;
}
</style>

View File

@@ -0,0 +1,62 @@
<script>
export default {
props: {
heading: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
active: {
type: Boolean,
default: false,
},
src: {
type: String,
default: '',
},
},
};
</script>
<template>
<div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid"
:class="{
'bg-n-blue-1 dark:bg-n-solid-2 border-n-blue-4': active,
'border-n-weak': !active,
}"
>
<div
class="flex justify-between items-center rounded-t-md px-2 w-full h-10 border-b border-solid"
:class="{
'bg-n-blue-2 border-n-blue-4': active,
'bg-n-slate-2 border-n-weak': !active,
}"
>
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
<fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="text-n-brand"
/>
</div>
<div
class="text-n-slate-11 text-xs leading-[1.4] px-3 pt-3 pb-0 text-start"
>
{{ content }}
</div>
<div v-if="src" class="p-3">
<img
:src="src"
class="border rounded-md"
:class="active ? 'border-n-blue-border' : 'border-n-weak'"
/>
</div>
<slot v-else />
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup>
import { ref, useTemplateRef, provide, computed, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
const props = defineProps({
index: {
type: Number,
default: 0,
},
border: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['change']);
const tabsContainer = useTemplateRef('tabsContainer');
const tabsList = useTemplateRef('tabsList');
const { width: containerWidth } = useElementSize(tabsContainer);
const { width: listWidth } = useElementSize(tabsList);
const hasScroll = ref(false);
const activeIndex = computed({
get: () => props.index,
set: newValue => {
emit('change', newValue);
},
});
provide('activeIndex', activeIndex);
provide('updateActiveIndex', index => {
activeIndex.value = index;
});
const computeScrollWidth = () => {
if (tabsContainer.value && tabsList.value) {
hasScroll.value = tabsList.value.scrollWidth > tabsList.value.clientWidth;
}
};
const onScrollClick = direction => {
if (tabsContainer.value && tabsList.value) {
let scrollPosition = tabsList.value.scrollLeft;
scrollPosition += direction === 'left' ? -100 : 100;
tabsList.value.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
});
}
};
// Watch for changes in element sizes with immediate execution
watch(
[containerWidth, listWidth],
() => {
computeScrollWidth();
},
{ immediate: true }
);
</script>
<template>
<div
ref="tabsContainer"
class="flex"
:class="[border && 'border-b border-b-n-weak']"
>
<button
v-if="hasScroll"
class="items-center rounded-none cursor-pointer flex h-auto justify-center min-w-8"
@click="onScrollClick('left')"
>
<fluent-icon icon="chevron-left" :size="16" />
</button>
<ul
ref="tabsList"
class="border-r-0 border-l-0 border-t-0 flex min-w-[6.25rem] py-0 px-4 list-none mb-0"
:class="
hasScroll ? 'overflow-hidden py-0 px-1 max-w-[calc(100%-64px)]' : ''
"
>
<slot />
</ul>
<button
v-if="hasScroll"
class="items-center rounded-none cursor-pointer flex h-auto justify-center min-w-8"
@click="onScrollClick('right')"
>
<fluent-icon icon="chevron-right" :size="16" />
</button>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
import { computed, inject } from 'vue';
const props = defineProps({
index: {
type: Number,
default: 0,
},
name: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
count: {
type: Number,
default: 0,
},
showBadge: {
type: Boolean,
default: true,
},
isCompact: {
type: Boolean,
default: false,
},
});
const activeIndex = inject('activeIndex');
const updateActiveIndex = inject('updateActiveIndex');
const active = computed(() => props.index === activeIndex.value);
const getItemCount = computed(() => props.count);
const onTabClick = event => {
event.preventDefault();
if (!props.disabled) {
updateActiveIndex(props.index);
}
};
</script>
<template>
<li
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
>
<a
class="flex items-center flex-row select-none cursor-pointer relative after:absolute after:bottom-px after:left-0 after:right-0 after:h-[2px] after:rounded-full after:transition-all after:duration-200 text-button"
:class="[
active
? 'text-n-blue-11 after:bg-n-brand after:opacity-100'
: 'text-n-slate-11 after:bg-transparent after:opacity-0',
isCompact ? 'py-2.5' : '!text-base py-3',
]"
@click="onTabClick"
>
{{ name }}
<div
v-if="showBadge"
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
:class="[
active
? 'bg-n-blue-3 text-n-blue-11'
: 'bg-n-alpha-1 text-n-slate-10',
]"
>
<span>
{{ getItemCount }}
</span>
</div>
</a>
</li>
</template>

View File

@@ -0,0 +1,120 @@
<script>
const MINUTE_IN_MILLI_SECONDS = 60000;
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
import {
dynamicTime,
dateFormat,
shortTimestamp,
} from 'shared/helpers/timeHelper';
export default {
name: 'TimeAgo',
props: {
isAutoRefreshEnabled: {
type: Boolean,
default: true,
},
lastActivityTimestamp: {
type: [String, Date, Number],
default: '',
},
createdAtTimestamp: {
type: [String, Date, Number],
default: '',
},
},
data() {
return {
lastActivityAtTimeAgo: dynamicTime(this.lastActivityTimestamp),
createdAtTimeAgo: dynamicTime(this.createdAtTimestamp),
timer: null,
};
},
computed: {
lastActivityTime() {
return shortTimestamp(this.lastActivityAtTimeAgo);
},
createdAtTime() {
return shortTimestamp(this.createdAtTimeAgo);
},
createdAt() {
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isBeforeAMonth
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
this.createdAtTimeAgo
}`
: `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST')} ${dateFormat(
this.createdAtTimestamp
)}`;
},
lastActivity() {
const lastActivityTimeDiff =
Date.now() - this.lastActivityTimestamp * 1000;
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
return !isNotActive
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
this.lastActivityAtTimeAgo
}`
: `${this.$t(
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
)} ${dateFormat(this.lastActivityTimestamp)}`;
},
tooltipText() {
return `${this.createdAt}
${this.lastActivity}`;
},
},
watch: {
lastActivityTimestamp() {
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
},
createdAtTimestamp() {
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
unmounted() {
clearTimeout(this.timer);
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
this.createTimer();
}, this.refreshTime());
},
refreshTime() {
const timeDiff = Date.now() - this.lastActivityTimestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
return HOUR_IN_MILLI_SECONDS;
}
return MINUTE_IN_MILLI_SECONDS;
},
},
};
</script>
<template>
<div
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
hideOnClick: true,
}"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
>
<span>{{ `${createdAtTime}${lastActivityTime}` }}</span>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
});
const route = useRoute();
const activeIndex = computed(() => {
const index = props.items.findIndex(i => i.route === route.name);
return index === -1 ? 0 : index;
});
const steps = computed(() =>
props.items.map((item, index) => {
const isActive = index === activeIndex.value;
const isOver = index < activeIndex.value;
return {
...item,
index,
isActive,
isOver,
};
})
);
</script>
<template>
<transition-group tag="div">
<div
v-for="step in steps"
:key="step.route"
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden after:bg-n-slate-3 before:bg-n-slate-3"
>
<!-- Circle -->
<div
class="rounded-2xl flex-shrink-0 size-8 border-2 border-n-slate-3 flex items-center justify-center left-2 leading-4 z-10 top-5 transition-all duration-300 ease-in-out"
:class="{
'bg-n-slate-3': step.isActive || step.isOver,
'bg-n-background': !step.isActive && !step.isOver,
}"
>
<span
v-if="!step.isOver"
:key="'num-' + step.index"
class="text-xs font-bold transition-colors duration-300"
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-11'"
>
{{ step.index + 1 }}
</span>
<Icon
v-else
:key="'check-' + step.index"
icon="i-lucide-check"
class="text-n-slate-11 size-4"
/>
</div>
<!-- Content -->
<div class="flex flex-col items-start gap-1.5 pb-10 pt-1">
<div class="flex items-center">
<h3
class="text-sm font-medium overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-12'"
>
{{ step.title }}
</h3>
</div>
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
{{ step.body }}
</p>
</div>
</div>
</transition-group>
</template>