mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-14 03:02:15 +01:00
@@ -1,11 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { computed, inject, ref, type ComputedRef } from 'vue';
|
||||
import {
|
||||
getLocalizedDayJs,
|
||||
formatDate,
|
||||
firstDayIndex,
|
||||
type WeekStartDay,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';
|
||||
import { Calendar } from '@/Components/ui/calendar';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { CalendarIcon } from 'lucide-vue-next';
|
||||
import { parseDate, type DateValue } from '@internationalized/date';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
tabindex?: string;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
// This has to be a localized timestamp, not UTC
|
||||
@@ -13,61 +23,64 @@ const model = defineModel<string | null>({
|
||||
default: null,
|
||||
});
|
||||
|
||||
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
|
||||
const emit = defineEmits(['changed']);
|
||||
|
||||
watch(model, (value) => {
|
||||
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
|
||||
const open = ref(false);
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||
|
||||
const dateString = computed(() => {
|
||||
if (!model.value) return null;
|
||||
return getLocalizedDayJs(model.value).format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
function updateDate(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const newValue = target.value;
|
||||
const newDate = getDayJsInstance()(newValue);
|
||||
if (newDate.isValid()) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('year', newDate.year())
|
||||
.set('month', newDate.month())
|
||||
.set('date', newDate.date())
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
const calendarDate = computed(() => {
|
||||
if (!dateString.value) return undefined;
|
||||
return parseDate(dateString.value);
|
||||
});
|
||||
|
||||
const displayDate = computed(() => {
|
||||
if (!dateString.value) return '';
|
||||
return formatDate(dateString.value, organization?.value?.date_format);
|
||||
});
|
||||
|
||||
function handleDateSelect(newDate: DateValue | undefined) {
|
||||
if (!newDate) return;
|
||||
// If model.value is null, start from current date/time
|
||||
const baseDate = model.value ? getLocalizedDayJs(model.value) : getLocalizedDayJs();
|
||||
const newValue = baseDate
|
||||
.set('year', newDate.year)
|
||||
.set('month', newDate.month - 1) // CalendarDate months are 1-indexed, dayjs is 0-indexed
|
||||
.set('date', newDate.day)
|
||||
.format();
|
||||
model.value = newValue;
|
||||
emit('changed', newValue);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
const datePicker = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function updateTempValue(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
tempDate.value = target.value;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['changed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center text-text-secondary">
|
||||
<input
|
||||
id="start"
|
||||
ref="datePicker"
|
||||
:tabindex="tabindex"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-input-background border text-text-primary border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
type="date"
|
||||
name="trip-start"
|
||||
:value="tempDate"
|
||||
@change="updateTempValue"
|
||||
@blur="updateDate"
|
||||
@keydown.enter="updateDate" />
|
||||
<div class="w-full">
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="input"
|
||||
size="sm"
|
||||
:tabindex="tabindex"
|
||||
:class="['w-full px-2 gap-1.5', props.class]">
|
||||
<CalendarIcon class="!size-3 text-muted-foreground" />
|
||||
<span>{{ displayDate || 'Pick a date' }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0" align="center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
:model-value="calendarDate"
|
||||
:week-starts-on="weekStartsOn"
|
||||
@update:model-value="handleDateSelect" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,11 +6,18 @@ import { CalendarDate } from '@internationalized/date';
|
||||
import { CalendarIcon } from 'lucide-vue-next';
|
||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import {
|
||||
getDayJsInstance,
|
||||
getLocalizedDayJs,
|
||||
firstDayIndex,
|
||||
type WeekStartDay,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { type Organization } from '@/packages/api/src';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -207,7 +214,8 @@ watch(open, (value) => {
|
||||
v-model="modelValue"
|
||||
initial-focus
|
||||
:number-of-months="2"
|
||||
:max-value="today" />
|
||||
:max-value="today"
|
||||
:week-starts-on="weekStartsOn" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import Button from '../Buttons/Button.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
@@ -61,9 +61,10 @@ const dropdownContent = ref();
|
||||
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
|
||||
<div class="px-2" @keydown.enter.prevent="nextTick(() => emit('close'))">
|
||||
<div class="font-semibold text-text-primary text-sm pb-2">Start</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-col items-center space-y-2 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="tempStart"
|
||||
class="w-full"
|
||||
data-testid="time_entry_range_start"
|
||||
tabindex="0"
|
||||
:focus
|
||||
@@ -71,35 +72,37 @@ const dropdownContent = ref();
|
||||
@changed="updateTimeEntry"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="tempStart"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@changed="updateTimeEntry"
|
||||
@blur.stop.prevent="emit('close')"></DatePicker>
|
||||
class="w-full"
|
||||
@changed="updateTimeEntry"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2">
|
||||
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
|
||||
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
|
||||
<div
|
||||
v-if="end !== null && tempEnd !== null"
|
||||
class="flex flex-col items-center space-y-2 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="tempEnd"
|
||||
class="w-full"
|
||||
data-testid="time_entry_range_end"
|
||||
@changed="updateTimeEntry"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="tempEnd"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
class="w-full"
|
||||
@changed="updateTimeEntry"></DatePicker>
|
||||
</div>
|
||||
<div v-else-if="end === null && !showEndTimePicker">
|
||||
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
|
||||
</div>
|
||||
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
|
||||
<div
|
||||
v-else-if="showEndTimePicker && tempEnd !== null"
|
||||
class="flex flex-col items-center space-y-2 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="tempEnd"
|
||||
class="w-full"
|
||||
data-testid="time_entry_range_end"
|
||||
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="tempEnd"
|
||||
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
|
||||
@keydown.enter.prevent="confirmEndTime"></DatePicker>
|
||||
<DatePicker v-model="tempEnd" class="w-full"></DatePicker>
|
||||
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -235,22 +235,22 @@ const billableProxy = computed({
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,22 +251,25 @@ const billableProxy = computed({
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>Start</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localStart"
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="localStart"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div class="">
|
||||
<InputLabel>End</InputLabel>
|
||||
<div class="flex flex-col items-center space-y-2 mt-1">
|
||||
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
|
||||
<DatePicker
|
||||
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
|
||||
<TimePickerSimple
|
||||
v-model="localEnd"
|
||||
tabindex="1"
|
||||
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
|
||||
class="w-full"
|
||||
size="large"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,9 @@ const forwardedProps = useForwardProps(delegatedProps);
|
||||
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
|
||||
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selection Start
|
||||
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
|
||||
'data-[selection-start]:bg-quaternary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-quaternary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
|
||||
// Selection End
|
||||
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
|
||||
'data-[selection-end]:bg-quaternary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-quaternary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
|
||||
// Outside months
|
||||
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
|
||||
// Disabled
|
||||
|
||||
@@ -21,6 +21,9 @@ export type DateFormat =
|
||||
| 'hyphen-separated-mm-dd-yyyy'
|
||||
| 'hyphen-separated-yyyy-mm-dd';
|
||||
|
||||
// Day of week index type for calendar components (0 = Sunday, 6 = Saturday)
|
||||
export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
const dateFormatMap: Record<DateFormat, string> = {
|
||||
'point-separated-d-m-yyyy': 'D.M.YYYY',
|
||||
'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',
|
||||
|
||||
Reference in New Issue
Block a user