mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 03:32:15 +01:00
add timesheets page
This commit is contained in:
@@ -57,7 +57,7 @@ import type {
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||
(e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
|
||||
(e: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { getLocalizedDayJs } from '../utils/time';
|
||||
import { getWeekStart } from '../utils/settings';
|
||||
import { getWeekStartDayNumber } from '../utils/settings';
|
||||
|
||||
export function useCalendarNavigation(callbacks: {
|
||||
onDatesChange: (payload: { start: Date; end: Date }) => void;
|
||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||
scrollToCurrentTime: () => void;
|
||||
}) {
|
||||
const activeView = ref('timeGridWeek');
|
||||
const currentDate = ref(getLocalizedDayJs());
|
||||
|
||||
function getFirstDay(): number {
|
||||
const weekStart = getWeekStart();
|
||||
const weekStartMap: Record<string, number> = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
return weekStartMap[weekStart] ?? 1;
|
||||
return getWeekStartDayNumber();
|
||||
}
|
||||
|
||||
const viewDays = computed<Dayjs[]>(() => {
|
||||
@@ -67,8 +57,8 @@ export function useCalendarNavigation(callbacks: {
|
||||
const days = viewDays.value;
|
||||
if (days.length === 0) return;
|
||||
|
||||
const start = days[0]!.toDate();
|
||||
const end = days[days.length - 1]!.add(1, 'day').toDate();
|
||||
const start = days[0]!;
|
||||
const end = days[days.length - 1]!.add(1, 'day');
|
||||
callbacks.onDatesChange({ start, end });
|
||||
}
|
||||
|
||||
|
||||
145
resources/js/packages/ui/src/Input/DurationSecondsInput.vue
Normal file
145
resources/js/packages/ui/src/Input/DurationSecondsInput.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const organizationSettings = computed(() => ({
|
||||
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
|
||||
numberFormat: organization?.value?.number_format ?? 'point',
|
||||
}));
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: number | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
inputClass?: string;
|
||||
size?: 'sm' | 'base';
|
||||
defaultUnit?: 'auto' | 'hours' | 'minutes';
|
||||
}>(),
|
||||
{
|
||||
modelValue: null,
|
||||
placeholder: '-',
|
||||
disabled: false,
|
||||
inputClass: '',
|
||||
size: 'base',
|
||||
defaultUnit: 'auto',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null];
|
||||
commit: [value: number | null];
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const temporaryValue = ref('');
|
||||
const isEditing = ref(false);
|
||||
const hasPendingEdit = ref(false);
|
||||
const skipNextCommit = ref(false);
|
||||
|
||||
function formatModelValue(value: number | null | undefined): string {
|
||||
if (!value || value === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return formatHumanReadableDuration(
|
||||
value,
|
||||
organizationSettings.value.intervalFormat,
|
||||
organizationSettings.value.numberFormat
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = computed({
|
||||
get() {
|
||||
if (isEditing.value) {
|
||||
return temporaryValue.value;
|
||||
}
|
||||
return formatModelValue(props.modelValue);
|
||||
},
|
||||
set(newValue: string) {
|
||||
temporaryValue.value = newValue;
|
||||
hasPendingEdit.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
function selectInput(event: Event) {
|
||||
isEditing.value = true;
|
||||
hasPendingEdit.value = false;
|
||||
skipNextCommit.value = false;
|
||||
temporaryValue.value = formatModelValue(props.modelValue);
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
|
||||
function resetEditingState() {
|
||||
temporaryValue.value = '';
|
||||
isEditing.value = false;
|
||||
hasPendingEdit.value = false;
|
||||
}
|
||||
|
||||
function commitValue() {
|
||||
if (skipNextCommit.value) {
|
||||
skipNextCommit.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const input = temporaryValue.value.trim();
|
||||
const shouldCommit = hasPendingEdit.value;
|
||||
resetEditingState();
|
||||
|
||||
if (!shouldCommit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Blank or literal "0" → null. Consumers decide what null means
|
||||
// (clear estimate, delete cell, etc.) by reading their own emit.
|
||||
if (input === '' || input === '0') {
|
||||
emit('update:modelValue', null);
|
||||
emit('commit', null);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultUnit =
|
||||
props.defaultUnit === 'auto'
|
||||
? organizationSettings.value.intervalFormat === 'decimal'
|
||||
? 'hours'
|
||||
: 'minutes'
|
||||
: props.defaultUnit;
|
||||
const seconds = parseTimeInput(input, organizationSettings.value.numberFormat, defaultUnit);
|
||||
|
||||
if (seconds !== null && seconds >= 0) {
|
||||
emit('update:modelValue', seconds);
|
||||
emit('commit', seconds);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit(event: Event) {
|
||||
skipNextCommit.value = true;
|
||||
resetEditingState();
|
||||
(event.target as HTMLInputElement).blur();
|
||||
}
|
||||
|
||||
function commitAndSubmit() {
|
||||
commitValue();
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TextInput
|
||||
v-model="displayValue"
|
||||
data-testid="duration_seconds_input"
|
||||
name="Duration"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:placeholder="isEditing ? '0' : placeholder"
|
||||
:class="inputClass"
|
||||
@focus="selectInput"
|
||||
@blur="commitValue"
|
||||
@keydown.enter.prevent="commitAndSubmit"
|
||||
@keydown.escape="cancelEdit" />
|
||||
</template>
|
||||
@@ -1,12 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch, inject } from 'vue';
|
||||
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
|
||||
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { TextInput } from '@/packages/ui/src';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import { type ComputedRef } from 'vue';
|
||||
|
||||
const temporaryInput = ref<string>('');
|
||||
|
||||
const model = defineModel<number | null>({
|
||||
default: null,
|
||||
@@ -16,64 +10,16 @@ const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
function updateDuration() {
|
||||
const input = temporaryInput.value.trim();
|
||||
|
||||
if (input === '') {
|
||||
model.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
|
||||
if (seconds !== null && seconds > 0) {
|
||||
model.value = seconds;
|
||||
}
|
||||
|
||||
updateInputDisplay();
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
watch(model, updateInputDisplay);
|
||||
onMounted(() => updateInputDisplay());
|
||||
|
||||
function updateInputDisplay() {
|
||||
if (model.value !== null && model.value > 0) {
|
||||
temporaryInput.value = formatHumanReadableDuration(
|
||||
model.value,
|
||||
organization?.value?.interval_format,
|
||||
organization?.value?.number_format
|
||||
);
|
||||
} else {
|
||||
temporaryInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function selectInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
target.select();
|
||||
}
|
||||
|
||||
function updateAndSubmit() {
|
||||
updateDuration();
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputField"
|
||||
v-model="temporaryInput"
|
||||
:class="twMerge('text-text-secondary', props.class)"
|
||||
type="text"
|
||||
<DurationSecondsInput
|
||||
v-model="model"
|
||||
:input-class="twMerge('placeholder:text-text-tertiary', props.class)"
|
||||
placeholder="e.g. 2h 30m or 1.5"
|
||||
@focus="selectInput"
|
||||
@blur="updateDuration"
|
||||
@keydown.enter="updateAndSubmit" />
|
||||
default-unit="hours"
|
||||
@submit="emit('submit')" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string;
|
||||
class?: string;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name?: string;
|
||||
class?: string;
|
||||
size?: 'sm' | 'base';
|
||||
}>(),
|
||||
{ size: 'base' }
|
||||
);
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -17,6 +21,10 @@ onMounted(() => {
|
||||
|
||||
defineExpose({ focus: () => input.value?.focus() });
|
||||
const model = defineModel();
|
||||
|
||||
const sizeClasses = computed(() =>
|
||||
props.size === 'sm' ? 'h-7 px-2 py-0.5 text-xs' : 'h-9 px-3 py-1 text-base sm:text-sm'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,7 +33,8 @@ const model = defineModel();
|
||||
v-model="model"
|
||||
:class="
|
||||
twMerge(
|
||||
'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
|
||||
'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
|
||||
sizeClasses,
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -519,29 +519,33 @@ const showCreateProject = ref(false);
|
||||
</template>
|
||||
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
|
||||
<template #trigger>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||
<span class="truncate shrink-[1] pr-1">{{ selectedProjectName }}</span>
|
||||
<template v-if="currentTask">
|
||||
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
|
||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<button
|
||||
v-if="allowReset && project !== null"
|
||||
type="button"
|
||||
data-testid="project_reset_button"
|
||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||
@click.stop="resetProject">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<slot name="trigger">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:variant="props.variant"
|
||||
:size="props.size"
|
||||
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
:style="{ backgroundColor: selectedProjectColor }"></div>
|
||||
<span class="truncate shrink-[1] text-text-primary pr-1">{{
|
||||
selectedProjectName
|
||||
}}</span>
|
||||
<template v-if="currentTask">
|
||||
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
|
||||
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
<button
|
||||
v-if="allowReset && project !== null"
|
||||
type="button"
|
||||
data-testid="project_reset_button"
|
||||
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
|
||||
@click.stop="resetProject">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #content>
|
||||
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">
|
||||
|
||||
@@ -8,6 +8,20 @@ export function getWeekStart() {
|
||||
}
|
||||
return weekStart;
|
||||
}
|
||||
|
||||
const weekStartMap: Record<string, number> = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
|
||||
export function getWeekStartDayNumber(): number {
|
||||
return weekStartMap[getWeekStart()] ?? 1;
|
||||
}
|
||||
export function getUserTimezone() {
|
||||
const timezone = window?.getTimezoneSetting() as string;
|
||||
if (!timezone) {
|
||||
|
||||
@@ -188,6 +188,15 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
|
||||
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a local Date to a UTC-formatted ISO string.
|
||||
* Treats the Date as being in the user's timezone and converts to UTC.
|
||||
* This is the inverse of getLocalizedDayJs (which goes UTC → local).
|
||||
*/
|
||||
export function localDateToUtc(date: dayjs.Dayjs): string {
|
||||
return date.tz(getUserTimezone(), true).utc().format();
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a formatted date.
|
||||
* @param date - date in the format of 'YYYY-MM-DD'
|
||||
|
||||
Reference in New Issue
Block a user