mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-15 19:52:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
@@ -9,7 +9,7 @@ export const buttonVariants = cva(
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 dark:bg-destructive/70',
|
||||
outline:
|
||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
|
||||
@@ -94,6 +94,7 @@ const emit = defineEmits<{
|
||||
getEventOpacityClass(dayEvent, dayStr),
|
||||
{
|
||||
'running-entry rounded-b-none': dayEvent.event.isRunning,
|
||||
'fc-event-break': dayEvent.event.isBreak,
|
||||
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
|
||||
'fc-event-resizing': resizeEventId === dayEvent.event.id,
|
||||
'rounded-t-none': dayEvent.isClippedStart,
|
||||
@@ -121,6 +122,8 @@ const emit = defineEmits<{
|
||||
:project-name="dayEvent.event.project?.name"
|
||||
:task-name="dayEvent.event.task?.name"
|
||||
:client-name="dayEvent.event.client?.name"
|
||||
:is-break="dayEvent.event.isBreak"
|
||||
:is-misplaced-break="dayEvent.event.isMisplacedBreak"
|
||||
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
|
||||
</div>
|
||||
<div
|
||||
@@ -413,4 +416,15 @@ const emit = defineEmits<{
|
||||
.fc-events-inset-expanded {
|
||||
left: 204px;
|
||||
}
|
||||
|
||||
/* Breaks get a hatched texture so they can not be confused with a project color */
|
||||
.fc-event-break {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
rgba(217, 119, 6, 0.15) 5px,
|
||||
rgba(217, 119, 6, 0.15) 7px
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { Dayjs } from 'dayjs';
|
||||
const props = defineProps<{
|
||||
date: Dayjs;
|
||||
totalSeconds?: number;
|
||||
breakSeconds?: number;
|
||||
isToday?: boolean;
|
||||
}>();
|
||||
|
||||
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
|
||||
const breakSecondsValue = computed(() => props.breakSeconds ?? 0);
|
||||
|
||||
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
|
||||
const intervalFormat = computed(() => organization?.value?.interval_format);
|
||||
@@ -24,6 +26,10 @@ const numberFormat = computed(() => organization?.value?.number_format);
|
||||
</div>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
|
||||
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
|
||||
<template v-if="breakSecondsValue > 0">
|
||||
· {{ formatHumanReadableDuration(breakSecondsValue, intervalFormat, numberFormat) }}
|
||||
break
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
@@ -11,6 +13,8 @@ const props = defineProps<{
|
||||
durationSeconds?: number;
|
||||
start?: string | Date | null;
|
||||
end?: string | Date | null;
|
||||
isBreak?: boolean;
|
||||
isMisplacedBreak?: boolean;
|
||||
}>();
|
||||
|
||||
const effectiveDurationSeconds = computed(() => {
|
||||
@@ -41,7 +45,15 @@ const formattedDuration = computed(() =>
|
||||
|
||||
<template>
|
||||
<div class="text-2xs leading-tight px-0.5 py-1">
|
||||
<div class="font-semibold">{{ title }}</div>
|
||||
<div class="font-semibold flex items-center gap-1">
|
||||
<Coffee v-if="isBreak" class="w-3 h-3 shrink-0" />
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<ExclamationTriangleIcon
|
||||
v-if="isMisplacedBreak"
|
||||
data-testid="calendar_break_placement_hint"
|
||||
title="This break does not align with your work entries"
|
||||
class="w-3 h-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div v-if="projectName" class="font-medium opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from 'vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { useCssVariable } from '../utils/useCssVariable';
|
||||
import { getLocalizedDayJs } from '../utils/time';
|
||||
import { useBreaksEnabled } from '../utils/useBreaksEnabled';
|
||||
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import BreakCreateModal from '../TimeEntry/BreakCreateModal.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import CalendarToolbar from './CalendarToolbar.vue';
|
||||
import CalendarDayColumn from './CalendarDayColumn.vue';
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
StopIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import type { ActivityPeriod } from './activityTypes';
|
||||
import { SLOT_HEIGHT, TIME_AXIS_WIDTH, type DayEvent } from './calendarTypes';
|
||||
import { useCalendarGrid } from './useCalendarGrid';
|
||||
@@ -74,6 +77,8 @@ const props = defineProps<{
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
organizationBillableRate: number | null;
|
||||
// Local date (YYYY-MM-DD) to open the calendar on, e.g. from a "Fix in calendar" deep link
|
||||
initialDate?: string | null;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
@@ -87,6 +92,9 @@ const props = defineProps<{
|
||||
|
||||
const newEventStart = ref<Dayjs | null>(null);
|
||||
const newEventEnd = ref<Dayjs | null>(null);
|
||||
const showCreateBreakModal = ref(false);
|
||||
const newBreakStart = ref<Dayjs | null>(null);
|
||||
const newBreakEnd = ref<Dayjs | null>(null);
|
||||
const showCreateTimeEntryModal = ref<boolean>(false);
|
||||
const showEditTimeEntryModal = ref<boolean>(false);
|
||||
const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
@@ -114,6 +122,7 @@ const currentTime = ref(getLocalizedDayJs());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const {
|
||||
slots,
|
||||
@@ -138,23 +147,34 @@ const {
|
||||
} = useCalendarNavigation({
|
||||
onDatesChange: (payload) => emit('dates-change', payload),
|
||||
scrollToCurrentTime: () => scrollToCurrentTime(),
|
||||
// Parse as local midnight in the user's timezone — getLocalizedDayJs would
|
||||
// treat the bare date as UTC midnight, landing on the previous local day
|
||||
// for negative UTC offsets
|
||||
initialDate: props.initialDate ? getLocalizedDayJsFromMinutes(props.initialDate, 0) : null,
|
||||
});
|
||||
|
||||
const cssBackground = useCssVariable('--color-bg-background');
|
||||
|
||||
const { optimisticOverrides, calendarEvents, eventsByDay, dailyTotals, isToday, nowIndicatorTop } =
|
||||
useCalendarEvents({
|
||||
timeEntries: () => props.timeEntries,
|
||||
projects: () => props.projects,
|
||||
clients: () => props.clients,
|
||||
tasks: () => props.tasks,
|
||||
calendarSettings,
|
||||
viewDays,
|
||||
currentTime,
|
||||
cssBackground,
|
||||
minutesToPixels,
|
||||
timeToMinutesFromMidnight,
|
||||
});
|
||||
const {
|
||||
optimisticOverrides,
|
||||
calendarEvents,
|
||||
eventsByDay,
|
||||
dailyTotals,
|
||||
dailyBreakTotals,
|
||||
isToday,
|
||||
nowIndicatorTop,
|
||||
} = useCalendarEvents({
|
||||
timeEntries: () => props.timeEntries,
|
||||
projects: () => props.projects,
|
||||
clients: () => props.clients,
|
||||
tasks: () => props.tasks,
|
||||
calendarSettings,
|
||||
viewDays,
|
||||
currentTime,
|
||||
cssBackground,
|
||||
minutesToPixels,
|
||||
timeToMinutesFromMidnight,
|
||||
});
|
||||
|
||||
const {
|
||||
activityBoxesForDay,
|
||||
@@ -244,6 +264,7 @@ const {
|
||||
handleContextStop,
|
||||
handleContextDiscard,
|
||||
handleContextCreate,
|
||||
handleContextCreateBreak,
|
||||
} = useContextMenu({
|
||||
calendarSettings,
|
||||
calendarEvents,
|
||||
@@ -262,6 +283,11 @@ const {
|
||||
newEventEnd.value = end;
|
||||
showCreateTimeEntryModal.value = true;
|
||||
},
|
||||
onCreateBreak: (start, end) => {
|
||||
newBreakStart.value = start;
|
||||
newBreakEnd.value = end;
|
||||
showCreateBreakModal.value = true;
|
||||
},
|
||||
emitRefresh: () => emit('refresh'),
|
||||
});
|
||||
|
||||
@@ -274,6 +300,14 @@ watch(showCreateTimeEntryModal, (value) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(showCreateBreakModal, (value) => {
|
||||
if (!value) {
|
||||
newBreakStart.value = null;
|
||||
newBreakEnd.value = null;
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
watch(showEditTimeEntryModal, (value) => {
|
||||
if (!value) {
|
||||
selectedTimeEntry.value = null;
|
||||
@@ -455,6 +489,12 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
:start="newEventStart ? newEventStart.toISOString() : undefined"
|
||||
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
|
||||
|
||||
<BreakCreateModal
|
||||
v-model:show="showCreateBreakModal"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:start="newBreakStart ? newBreakStart.toISOString() : undefined"
|
||||
:end="newBreakEnd ? newBreakEnd.toISOString() : undefined" />
|
||||
|
||||
<TimeEntryEditModal
|
||||
v-model:show="showEditTimeEntryModal"
|
||||
:time-entry="selectedTimeEntry as any"
|
||||
@@ -516,8 +556,9 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<FullCalendarDayHeader
|
||||
:date="day"
|
||||
:is-today="isToday(day)"
|
||||
:total-seconds="
|
||||
dailyTotals[day.format('YYYY-MM-DD')] || 0
|
||||
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0"
|
||||
:break-seconds="
|
||||
dailyBreakTotals[day.format('YYYY-MM-DD')] || 0
|
||||
" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,11 +723,19 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Edit</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
|
||||
<!-- Duplicate/Split create a new entry of the same type, which the
|
||||
server rejects for breaks when breaks are disabled -->
|
||||
<ContextMenuItem
|
||||
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
||||
class="space-x-3"
|
||||
@select="handleContextDuplicate()">
|
||||
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Duplicate</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
|
||||
<ContextMenuItem
|
||||
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
|
||||
class="space-x-3"
|
||||
@select="handleContextSplit()">
|
||||
<ScissorsIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Split</span>
|
||||
</ContextMenuItem>
|
||||
@@ -716,6 +765,13 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
||||
<PlusIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Create Time Entry</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="breaksEnabled"
|
||||
class="space-x-3"
|
||||
@select="handleContextCreateBreak()">
|
||||
<Coffee class="w-4 h-4 text-icon-default" />
|
||||
<span>Add Break</span>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CalendarEvent {
|
||||
client?: Client;
|
||||
task?: Task;
|
||||
isRunning: boolean;
|
||||
isBreak: boolean;
|
||||
isMisplacedBreak: boolean;
|
||||
durationMinutes: number;
|
||||
title: string;
|
||||
backgroundColor: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, ref, type Ref, type ComputedRef } from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
|
||||
import { getBreakPlacementHint } from '../utils/breakPlacement';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import type { CalendarEvent, DayEvent } from './calendarTypes';
|
||||
@@ -181,7 +182,8 @@ export function useCalendarEvents(params: {
|
||||
|
||||
const calendarEvents = computed<CalendarEvent[]>(() => {
|
||||
const themeBackground = params.cssBackground.value?.trim();
|
||||
return params.timeEntries().map((rawEntry) => {
|
||||
const allEntries = params.timeEntries();
|
||||
return allEntries.map((rawEntry) => {
|
||||
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
|
||||
const isRunning = timeEntry.end === null;
|
||||
const project = params.projects().find((p) => p.id === timeEntry.project_id);
|
||||
@@ -196,9 +198,20 @@ export function useCalendarEvents(params: {
|
||||
'minutes'
|
||||
);
|
||||
|
||||
const title = timeEntry.description || 'No description';
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const isBreak = timeEntry.type === 'break';
|
||||
const isMisplacedBreak = isBreak
|
||||
? (getBreakPlacementHint(timeEntry, allEntries)?.misplaced ?? false)
|
||||
: false;
|
||||
let title: string;
|
||||
if (isBreak) {
|
||||
title = timeEntry.description ? `Break · ${timeEntry.description}` : 'Break';
|
||||
} else {
|
||||
title = timeEntry.description || 'No description';
|
||||
}
|
||||
const baseColor = isBreak ? '#F59E0B' : project?.color || '#6B7280';
|
||||
const backgroundColor = chroma
|
||||
.mix(baseColor, themeBackground, isBreak ? 0.75 : 0.65, 'lab')
|
||||
.hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
@@ -215,6 +228,8 @@ export function useCalendarEvents(params: {
|
||||
client,
|
||||
task,
|
||||
isRunning,
|
||||
isBreak,
|
||||
isMisplacedBreak,
|
||||
durationMinutes,
|
||||
title,
|
||||
backgroundColor,
|
||||
@@ -253,28 +268,37 @@ export function useCalendarEvents(params: {
|
||||
return result;
|
||||
});
|
||||
|
||||
const dailyTotals = computed(() => {
|
||||
function computeDailyTotals(filter: (entry: TimeEntry) => boolean): Record<string, number> {
|
||||
const totals: Record<string, number> = {};
|
||||
params.timeEntries().forEach((entry) => {
|
||||
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
|
||||
let durationSeconds: number;
|
||||
params
|
||||
.timeEntries()
|
||||
.filter(filter)
|
||||
.forEach((entry) => {
|
||||
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
|
||||
let durationSeconds: number;
|
||||
|
||||
if (entry.end !== null) {
|
||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'seconds'
|
||||
);
|
||||
} else {
|
||||
durationSeconds = Math.max(
|
||||
0,
|
||||
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
|
||||
);
|
||||
}
|
||||
if (entry.end !== null) {
|
||||
durationSeconds = getDayJsInstance()(entry.end).diff(
|
||||
getDayJsInstance()(entry.start),
|
||||
'seconds'
|
||||
);
|
||||
} else {
|
||||
durationSeconds = Math.max(
|
||||
0,
|
||||
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
|
||||
);
|
||||
}
|
||||
|
||||
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||
});
|
||||
totals[date] = (totals[date] || 0) + durationSeconds;
|
||||
});
|
||||
return totals;
|
||||
});
|
||||
}
|
||||
|
||||
// Breaks are not working time: the day total only sums work entries,
|
||||
// the break portion is exposed separately
|
||||
const dailyTotals = computed(() => computeDailyTotals((entry) => entry.type !== 'break'));
|
||||
|
||||
const dailyBreakTotals = computed(() => computeDailyTotals((entry) => entry.type === 'break'));
|
||||
|
||||
function isToday(day: Dayjs): boolean {
|
||||
return day.isSame(getLocalizedDayJs(), 'day');
|
||||
@@ -294,6 +318,7 @@ export function useCalendarEvents(params: {
|
||||
calendarEvents,
|
||||
eventsByDay,
|
||||
dailyTotals,
|
||||
dailyBreakTotals,
|
||||
isToday,
|
||||
nowIndicatorTop,
|
||||
};
|
||||
|
||||
@@ -6,9 +6,10 @@ import { getWeekStartDayNumber } from '../utils/settings';
|
||||
export function useCalendarNavigation(callbacks: {
|
||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||
scrollToCurrentTime: () => void;
|
||||
initialDate?: Dayjs | null;
|
||||
}) {
|
||||
const activeView = ref('timeGridWeek');
|
||||
const currentDate = ref(getLocalizedDayJs());
|
||||
const currentDate = ref(callbacks.initialDate ?? getLocalizedDayJs());
|
||||
|
||||
function getFirstDay(): number {
|
||||
return getWeekStartDayNumber();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { CalendarEvent } from './calendarTypes';
|
||||
import { useContextMenu } from './useContextMenu';
|
||||
|
||||
function breakEntry(): TimeEntry {
|
||||
return {
|
||||
id: 'break-1',
|
||||
start: '2026-07-14T10:00:00Z',
|
||||
end: '2026-07-14T11:00:00Z',
|
||||
duration: 3600,
|
||||
description: 'Lunch',
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
organization_id: 'organization-1',
|
||||
user_id: 'user-1',
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'break',
|
||||
} as TimeEntry;
|
||||
}
|
||||
|
||||
describe('useContextMenu break actions', () => {
|
||||
const createTimeEntry = vi.fn().mockResolvedValue(undefined);
|
||||
const updateTimeEntry = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function contextMenu() {
|
||||
const entry = breakEntry();
|
||||
const calendarEvents = computed(() => [
|
||||
{
|
||||
id: entry.id,
|
||||
timeEntry: entry,
|
||||
} as CalendarEvent,
|
||||
]);
|
||||
const menu = useContextMenu({
|
||||
calendarSettings: ref({
|
||||
snapMinutes: 15,
|
||||
startHour: 0,
|
||||
endHour: 24,
|
||||
slotMinutes: 15,
|
||||
}),
|
||||
calendarEvents,
|
||||
pixelsToMinutesFromMidnight: () => 0,
|
||||
getDayFromClientX: () => null,
|
||||
clientYToGridPixels: () => 0,
|
||||
createTimeEntry,
|
||||
updateTimeEntry,
|
||||
deleteTimeEntry: vi.fn().mockResolvedValue(undefined),
|
||||
onEditEvent: vi.fn(),
|
||||
onCreateEvent: vi.fn(),
|
||||
onCreateBreak: vi.fn(),
|
||||
emitRefresh: vi.fn(),
|
||||
});
|
||||
|
||||
menu.handleCalendarContextMenu({
|
||||
target: {
|
||||
closest: () => ({
|
||||
getAttribute: () => entry.id,
|
||||
}),
|
||||
},
|
||||
} as unknown as MouseEvent);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
it('preserves the type when duplicating a break', async () => {
|
||||
await contextMenu().handleContextDuplicate();
|
||||
|
||||
expect(createTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the type when creating the second half of a split break', async () => {
|
||||
await contextMenu().handleContextSplit();
|
||||
|
||||
expect(updateTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
expect(createTimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, type Ref, type ComputedRef } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
|
||||
|
||||
import type { CalendarSettings } from './calendarSettings';
|
||||
import type { CalendarEvent } from './calendarTypes';
|
||||
@@ -19,6 +19,7 @@ export function useContextMenu(params: {
|
||||
deleteTimeEntry: (id: string) => Promise<void>;
|
||||
onEditEvent: (entry: TimeEntry) => void;
|
||||
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
|
||||
onCreateBreak: (start: Dayjs, end: Dayjs) => void;
|
||||
emitRefresh: () => void;
|
||||
}) {
|
||||
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
|
||||
@@ -73,6 +74,7 @@ export function useContextMenu(params: {
|
||||
start: entry.start,
|
||||
end: entry.end,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
@@ -108,6 +110,7 @@ export function useContextMenu(params: {
|
||||
start: midpoint.utc().format(),
|
||||
end: entry.end,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
project_id: entry.project_id,
|
||||
task_id: entry.task_id,
|
||||
@@ -154,6 +157,47 @@ export function useContextMenu(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextCreateBreak() {
|
||||
const dayjs = getDayJsInstance();
|
||||
if (!contextMenuCreateTime.value) {
|
||||
params.onCreateBreak(dayjs().utc().subtract(30, 'minute'), dayjs().utc());
|
||||
return;
|
||||
}
|
||||
const clickTime = contextMenuCreateTime.value.start;
|
||||
// Day matching must use the user's configured timezone (the calendar renders
|
||||
// its day columns in that timezone), not the browser's local timezone
|
||||
const clickDate = getLocalizedDayJs(clickTime.format()).format('YYYY-MM-DD');
|
||||
|
||||
// When the click lands in a gap between two entries of the same day,
|
||||
// the break is prefilled to exactly fill that gap
|
||||
let previousEnd: Dayjs | null = null;
|
||||
let nextStart: Dayjs | null = null;
|
||||
for (const calendarEvent of params.calendarEvents.value) {
|
||||
const entry = calendarEvent.timeEntry;
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
if (getLocalizedDayJs(entry.start).format('YYYY-MM-DD') !== clickDate) {
|
||||
continue;
|
||||
}
|
||||
const entryEnd = entry.end === null ? null : dayjs.utc(entry.end);
|
||||
if (entryEnd !== null && !entryEnd.isAfter(clickTime)) {
|
||||
if (previousEnd === null || entryEnd.isAfter(previousEnd)) {
|
||||
previousEnd = entryEnd;
|
||||
}
|
||||
}
|
||||
if (!entryStart.isBefore(clickTime)) {
|
||||
if (nextStart === null || entryStart.isBefore(nextStart)) {
|
||||
nextStart = entryStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousEnd !== null && nextStart !== null && previousEnd.isBefore(nextStart)) {
|
||||
params.onCreateBreak(previousEnd, nextStart);
|
||||
return;
|
||||
}
|
||||
params.onCreateBreak(contextMenuCreateTime.value.start, contextMenuCreateTime.value.end);
|
||||
}
|
||||
|
||||
return {
|
||||
contextMenuTimeEntry,
|
||||
contextMenuCreateTime,
|
||||
@@ -165,5 +209,6 @@ export function useContextMenu(params: {
|
||||
handleContextStop,
|
||||
handleContextDiscard,
|
||||
handleContextCreate,
|
||||
handleContextCreateBreak,
|
||||
};
|
||||
}
|
||||
|
||||
121
resources/js/packages/ui/src/TimeEntry/BreakCreateModal.vue
Normal file
121
resources/js/packages/ui/src/TimeEntry/BreakCreateModal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type { CreateTimeEntryBody } from '@/packages/api/src';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}>();
|
||||
|
||||
function defaultStart() {
|
||||
return getDayJsInstance().utc().subtract(30, 'm').second(0).format();
|
||||
}
|
||||
|
||||
function defaultEnd() {
|
||||
return getDayJsInstance().utc().second(0).format();
|
||||
}
|
||||
|
||||
const note = ref('');
|
||||
const localStart = ref(getLocalizedDayJs(defaultStart()).format());
|
||||
const localEnd = ref(getLocalizedDayJs(defaultEnd()).format());
|
||||
|
||||
// Prefill start/end when the modal is opened with a given range (e.g. from the calendar)
|
||||
watch(
|
||||
() => props.start,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localStart.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => props.end,
|
||||
(value) => {
|
||||
if (value) {
|
||||
localEnd.value = getLocalizedDayJs(value).format();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const durationSeconds = computed(() =>
|
||||
getLocalizedDayJs(localEnd.value).diff(getLocalizedDayJs(localStart.value), 'second')
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (durationSeconds.value <= 0) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.createTimeEntry({
|
||||
description: note.value,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'break',
|
||||
start: getLocalizedDayJs(localStart.value).utc().format(),
|
||||
end: getLocalizedDayJs(localEnd.value).utc().format(),
|
||||
});
|
||||
note.value = '';
|
||||
localStart.value = getLocalizedDayJs(defaultStart()).format();
|
||||
localEnd.value = getLocalizedDayJs(defaultEnd()).format();
|
||||
show.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
|
||||
<Coffee class="w-5 h-5" />
|
||||
<span> Add break </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
date-picker-size="sm"></TimeRangeFields>
|
||||
<Field>
|
||||
<FieldLabel for="break_note">Note (optional)</FieldLabel>
|
||||
<TextInput
|
||||
id="break_note"
|
||||
v-model="note"
|
||||
placeholder="e.g. Lunch"
|
||||
type="text"
|
||||
class="block w-full"
|
||||
@keydown.enter="submit" />
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
tabindex="2"
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving || durationSeconds <= 0"
|
||||
@click="submit">
|
||||
Add Break
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
14
resources/js/packages/ui/src/TimeEntry/BreakLabel.vue
Normal file
14
resources/js/packages/ui/src/TimeEntry/BreakLabel.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { Coffee } from '@lucide/vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="break_badge"
|
||||
class="flex items-center space-x-1.5 text-sm font-medium text-text-secondary">
|
||||
<Coffee class="w-4 h-4 text-text-tertiary" />
|
||||
<span>Break</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/20/solid';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
|
||||
|
||||
// Warning affordance for a misplaced break: an amber triangle that opens a small
|
||||
// popover offering to jump to the break's day in the calendar. Rendered by the
|
||||
// caller only when the break is actually misplaced (see `showPlacementHint`).
|
||||
defineProps<{
|
||||
// Local day (YYYY-MM-DD) the calendar should navigate to.
|
||||
fixDate: string;
|
||||
// Delegated navigation — packages/ui stays router-agnostic.
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="break_placement_hint"
|
||||
title="This break does not align with your work entries"
|
||||
class="flex items-center justify-center shrink-0 rounded-full p-0.5 text-amber-500 hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ExclamationTriangleIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="min-w-[260px]" align="start">
|
||||
<div class="px-3 py-2 space-y-1.5">
|
||||
<p class="text-xs text-text-secondary">
|
||||
This break is not directly between work entries.
|
||||
</p>
|
||||
<button
|
||||
v-if="fixInCalendar"
|
||||
type="button"
|
||||
data-testid="break_fix_in_calendar"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline"
|
||||
@click="fixInCalendar(fixDate)">
|
||||
Fix in calendar
|
||||
<ArrowRightIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -16,11 +16,19 @@ import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTag
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';
|
||||
import { ref, inject, computed, type ComputedRef } from 'vue';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatStartEnd,
|
||||
getLocalizedDayJs,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
|
||||
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
|
||||
import {
|
||||
findMisplacedBreak,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
import {
|
||||
Checkbox,
|
||||
ContextMenu,
|
||||
@@ -30,6 +38,9 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import { PlayIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
|
||||
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntriesGroupedByType;
|
||||
@@ -50,6 +61,8 @@ const props = defineProps<{
|
||||
selectedTimeEntries: TimeEntry[];
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
breakPlacementHints?: Record<string, BreakPlacementHint | null>;
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
selected: [TimeEntry[]];
|
||||
@@ -57,6 +70,26 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
// Continue creates a new entry of the same type, which the server rejects
|
||||
// for breaks when breaks are disabled for the organization
|
||||
const canRecreate = computed(() => props.timeEntry.type !== 'break' || breaksEnabled.value);
|
||||
|
||||
// Grouped breaks collapse into a single summary row, so surface the placement
|
||||
// warning if any entry in the group is misplaced. All grouped entries share the
|
||||
// same day, so the first misplaced one supplies the calendar navigation date.
|
||||
const misplacedBreakEntry = computed<TimeEntry | null>(() =>
|
||||
findMisplacedBreak(props.timeEntry.timeEntries, props.breakPlacementHints ?? {})
|
||||
);
|
||||
const showPlacementHint = computed(
|
||||
() => props.timeEntry.type === 'break' && misplacedBreakEntry.value !== null
|
||||
);
|
||||
const breakFixDate = computed(() =>
|
||||
misplacedBreakEntry.value
|
||||
? getLocalizedDayJs(misplacedBreakEntry.value.start).format('YYYY-MM-DD')
|
||||
: ''
|
||||
);
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntries(
|
||||
@@ -121,12 +154,26 @@ function onSelectChange(checked: boolean) {
|
||||
{{ timeEntry?.timeEntries?.length }}
|
||||
</GroupedItemsCountButton>
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
class="min-w-0 mr-4 shrink"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="
|
||||
updateTimeEntryDescription
|
||||
"></TimeEntryDescriptionInput>
|
||||
<BreakLabel
|
||||
v-if="timeEntry.type === 'break'"
|
||||
class="px-2 shrink-0" />
|
||||
<span
|
||||
v-if="timeEntry.type === 'break' && timeEntry.description"
|
||||
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
class="min-w-0 shrink"
|
||||
:clients
|
||||
:create-project
|
||||
@@ -147,11 +194,13 @@ function onSelectChange(checked: boolean) {
|
||||
<div
|
||||
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
faded
|
||||
@@ -189,6 +238,7 @@ function onSelectChange(checked: boolean) {
|
||||
</button>
|
||||
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100"
|
||||
@@ -231,7 +281,17 @@ function onSelectChange(checked: boolean) {
|
||||
</div>
|
||||
<!-- Second row: project/task - tags - billable - start - more -->
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<div
|
||||
v-if="timeEntry.type === 'break'"
|
||||
class="flex items-center min-w-0">
|
||||
<BreakLabel class="px-2 min-w-0" />
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
</div>
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-else
|
||||
class="min-w-0"
|
||||
:clients
|
||||
:create-project
|
||||
@@ -249,16 +309,19 @@ function onSelectChange(checked: boolean) {
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
<div class="flex items-center shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
compact
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="timeEntry.type !== 'break'"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
@@ -278,7 +341,7 @@ function onSelectChange(checked: boolean) {
|
||||
</MainContainer>
|
||||
<div
|
||||
v-if="expanded"
|
||||
class="w-full border-t border-default-background-separator bg-black/15">
|
||||
class="w-full border-t border-default-background-separator bg-black/5 dark:bg-black/15">
|
||||
<TimeEntryRow
|
||||
v-for="subEntry in timeEntry.timeEntries"
|
||||
:key="subEntry.id"
|
||||
@@ -303,6 +366,8 @@ function onSelectChange(checked: boolean) {
|
||||
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
|
||||
:currency="currency"
|
||||
:create-tag
|
||||
:placement-hint="breakPlacementHints?.[subEntry.id] ?? null"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:time-entry="subEntry"
|
||||
@selected="emit('selected', [subEntry])"
|
||||
@unselected="emit('unselected', [subEntry])"></TimeEntryRow>
|
||||
@@ -311,12 +376,13 @@ function onSelectChange(checked: boolean) {
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="min-w-[160px]">
|
||||
<ContextMenuItem
|
||||
v-if="canRecreate"
|
||||
class="space-x-3"
|
||||
@select="onStartStopClick(timeEntry.timeEntries[0]!)">
|
||||
<PlayIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Continue</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSeparator v-if="canRecreate" />
|
||||
<ContextMenuItem
|
||||
class="space-x-3 text-destructive"
|
||||
@select="deleteTimeEntries(timeEntry?.timeEntries ?? [])">
|
||||
|
||||
@@ -5,7 +5,6 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import type {
|
||||
@@ -19,12 +18,8 @@ import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -62,6 +57,7 @@ const timeEntryDefaultValues = {
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'work' as CreateTimeEntryBody['type'],
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
|
||||
end: getDayJsInstance().utc().second(0).format(),
|
||||
};
|
||||
@@ -107,9 +103,6 @@ const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
|
||||
|
||||
watch(localStart, (value) => {
|
||||
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
|
||||
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
|
||||
localEnd.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(localEnd, (value) => {
|
||||
@@ -202,39 +195,11 @@ const billableProxy = computed({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4">
|
||||
<Field class="col-span-2 sm:col-span-3">
|
||||
<FieldLabel>Duration</FieldLabel>
|
||||
<div class="space-y-2 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Start</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="localStart" class="w-full"></TimePickerSimple>
|
||||
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="localEnd" class="w-full"></TimePickerSimple>
|
||||
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
show-hint
|
||||
class="pt-4"></TimeRangeFields>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
@@ -23,8 +23,14 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import type { Tag, Task } from '@/packages/api/src';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
|
||||
// Breaks may have been disabled after this entry was created, so an existing break can still be
|
||||
// edited (and converted back), but a work entry may only offer the break option when enabled.
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -137,6 +143,24 @@ const billableProxy = computed({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isBreak = computed(() => editableTimeEntry.value?.type === 'break');
|
||||
|
||||
const typeProxy = computed({
|
||||
get: () => editableTimeEntry.value?.type ?? 'work',
|
||||
set: (value: string) => {
|
||||
if (editableTimeEntry.value) {
|
||||
editableTimeEntry.value.type = value as TimeEntry['type'];
|
||||
if (value === 'break') {
|
||||
// Breaks can not be billable, have tags or belong to a project/task
|
||||
editableTimeEntry.value.project_id = null;
|
||||
editableTimeEntry.value.task_id = null;
|
||||
editableTimeEntry.value.billable = false;
|
||||
editableTimeEntry.value.tags = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,7 +186,7 @@ const billableProxy = computed({
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-end gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="!isBreak" class="flex-1 min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="editableTimeEntry.project_id"
|
||||
v-model:task="editableTimeEntry.task_id"
|
||||
@@ -178,8 +202,24 @@ const billableProxy = computed({
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime" />
|
||||
</div>
|
||||
<div v-else class="flex-1 min-w-0"></div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Select v-if="breaksEnabled || isBreak" v-model="typeProxy">
|
||||
<SelectTrigger :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<Coffee
|
||||
class="h-4 w-4"
|
||||
:class="isBreak ? 'text-amber-500' : 'text-icon-default'" />
|
||||
<span>{{ isBreak ? 'Break' : 'Work time' }}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="work">Work time</SelectItem>
|
||||
<SelectItem value="break">Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TagDropdown
|
||||
v-if="!isBreak"
|
||||
v-model="editableTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@@ -195,7 +235,7 @@ const billableProxy = computed({
|
||||
</Button>
|
||||
</template>
|
||||
</TagDropdown>
|
||||
<Select v-model="billableProxy">
|
||||
<Select v-if="!isBreak" v-model="billableProxy">
|
||||
<SelectTrigger :show-chevron="false">
|
||||
<SelectValue class="flex items-center gap-2">
|
||||
<BillableIcon class="h-4 text-icon-default" />
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
|
||||
import {
|
||||
getBreakPlacementHint,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
|
||||
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
|
||||
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
|
||||
@@ -39,12 +43,24 @@ const props = withDefaults(
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
groupSimilarTimeEntries?: boolean;
|
||||
// Host-provided navigation to the calendar for a break's day (YYYY-MM-DD)
|
||||
fixInCalendar?: (date: string) => void;
|
||||
}>(),
|
||||
{
|
||||
groupSimilarTimeEntries: true,
|
||||
}
|
||||
);
|
||||
|
||||
const breakPlacementHints = computed<Record<string, BreakPlacementHint | null>>(() => {
|
||||
const hints: Record<string, BreakPlacementHint | null> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
if (entry.type === 'break') {
|
||||
hints[entry.id] = getBreakPlacementHint(entry, props.timeEntries);
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
});
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
@@ -75,6 +91,7 @@ const groupedTimeEntries = computed(() => {
|
||||
e.project_id === entry.project_id &&
|
||||
e.task_id === entry.task_id &&
|
||||
e.billable === entry.billable &&
|
||||
e.type === entry.type &&
|
||||
e.description === entry.description
|
||||
);
|
||||
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
|
||||
@@ -113,13 +130,24 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
|
||||
start: getDayJsInstance().utc().format(),
|
||||
end: null,
|
||||
billable: entry.billable,
|
||||
type: entry.type,
|
||||
description: entry.description,
|
||||
tags: [...entry.tags],
|
||||
});
|
||||
}
|
||||
|
||||
function sumDuration(timeEntries: TimeEntry[]) {
|
||||
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
// Breaks are not working time: the day total only sums work entries,
|
||||
// the break portion is shown separately in the heading
|
||||
return timeEntries
|
||||
.filter((entry) => entry.type !== 'break')
|
||||
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
}
|
||||
|
||||
function sumBreakDuration(timeEntries: TimeEntry[]) {
|
||||
return timeEntries
|
||||
.filter((entry) => entry.type === 'break')
|
||||
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
|
||||
}
|
||||
function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
for (const timeEntry of value) {
|
||||
@@ -151,6 +179,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
<TimeEntryRowHeading
|
||||
:date="String(key)"
|
||||
:duration="sumDuration(value)"
|
||||
:break-duration="sumBreakDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@@ -176,6 +205,8 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:break-placement-hints="breakPlacementHints"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
@@ -213,6 +244,9 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:placement-hint="breakPlacementHints[entry.timeEntries[0]!.id] ?? null"
|
||||
:fix-in-calendar="fixInCalendar"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]!"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type UpdateMultipleTimeEntriesChangeset,
|
||||
} from '@/packages/api/src';
|
||||
import { Checkbox } from '@/packages/ui/src';
|
||||
import { TagIcon } from '@heroicons/vue/20/solid';
|
||||
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
@@ -129,6 +129,21 @@ watch(removeAllTags, () => {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const selectedBreaksCount = computed(
|
||||
() => props.timeEntries.filter((entry) => entry.type === 'break').length
|
||||
);
|
||||
|
||||
// Mirrors the server-side skip in TimeEntryController::updateMultiple: a break
|
||||
// entry is skipped entirely when the changeset assigns a project, makes it
|
||||
// billable, or adds tags (clearing tags via removeAllTags is fine).
|
||||
const showBreakWarning = computed(
|
||||
() =>
|
||||
selectedBreaksCount.value > 0 &&
|
||||
((projectId.value !== null && projectId.value !== '') ||
|
||||
billable.value === true ||
|
||||
selectedTags.value.length > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -141,6 +156,20 @@ watch(removeAllTags, () => {
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="showBreakWarning"
|
||||
data-testid="mass_update_break_warning"
|
||||
class="flex items-start space-x-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
|
||||
<ExclamationTriangleIcon class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
{{ selectedBreaksCount }}
|
||||
{{ selectedBreaksCount === 1 ? 'break is' : 'breaks are' }} selected —
|
||||
breaks can not have a project or tags, or be billable, so
|
||||
{{ selectedBreaksCount === 1 ? 'this entry' : 'these entries' }} will be
|
||||
skipped entirely and none of the changes (including the description) will be
|
||||
applied to {{ selectedBreaksCount === 1 ? 'it' : 'them' }}.
|
||||
</span>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel for="description">Description</FieldLabel>
|
||||
<TextInput
|
||||
|
||||
@@ -18,6 +18,8 @@ import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowD
|
||||
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
|
||||
import { TimeEntryEditModal } from '@/packages/ui/src';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
|
||||
import { computed, ref } from 'vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import {
|
||||
@@ -29,6 +31,10 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import { PlayIcon, PencilIcon, DocumentDuplicateIcon, TrashIcon } from '@heroicons/vue/20/solid';
|
||||
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
|
||||
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
|
||||
import type { BreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||
import type { CreateTimeEntryBody } from '@/packages/api/src';
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntry: TimeEntry;
|
||||
@@ -45,6 +51,9 @@ const props = defineProps<{
|
||||
deleteTimeEntry: () => void;
|
||||
duplicateTimeEntry?: () => void;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => void;
|
||||
createTimeEntry?: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
|
||||
placementHint?: BreakPlacementHint | null;
|
||||
fixInCalendar?: (date: string) => void;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
showMember?: boolean;
|
||||
@@ -59,6 +68,20 @@ const emit = defineEmits<{ selected: []; unselected: [] }>();
|
||||
|
||||
const showEditModal = ref(false);
|
||||
|
||||
const breaksEnabled = useBreaksEnabled();
|
||||
|
||||
const isBreak = computed(() => props.timeEntry.type === 'break');
|
||||
|
||||
// Continue/Duplicate create a new entry of the same type, which the server
|
||||
// rejects for breaks when breaks are disabled for the organization
|
||||
const canRecreate = computed(() => !isBreak.value || breaksEnabled.value);
|
||||
|
||||
const showPlacementHint = computed(
|
||||
() => isBreak.value && props.placementHint != null && props.placementHint.misplaced
|
||||
);
|
||||
|
||||
const breakFixDate = computed(() => getLocalizedDayJs(props.timeEntry.start).format('YYYY-MM-DD'));
|
||||
|
||||
function updateTimeEntryDescription(description: string) {
|
||||
props.updateTimeEntry({ ...props.timeEntry, description });
|
||||
}
|
||||
@@ -131,10 +154,22 @@ async function handleDeleteTimeEntry() {
|
||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 mr-4 shrink"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
|
||||
<BreakLabel v-if="isBreak" class="pl-1.5 @lg:pl-3 pr-2 shrink-0" />
|
||||
<span
|
||||
v-if="isBreak && timeEntry.description"
|
||||
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<BreakPlacementHintButton
|
||||
v-if="showPlacementHint"
|
||||
:fix-date="breakFixDate"
|
||||
:fix-in-calendar="fixInCalendar" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 shrink"
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -154,11 +189,13 @@ async function handleDeleteTimeEntry() {
|
||||
{{ memberName }}
|
||||
</div>
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="!isBreak"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="!isBreak"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
faded
|
||||
@@ -176,11 +213,13 @@ async function handleDeleteTimeEntry() {
|
||||
:is-report="props.isReport"
|
||||
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="opacity-60 flex focus-visible:opacity-100 group-hover:opacity-100"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-duplicate="canRecreate"
|
||||
@edit="handleEdit"
|
||||
@duplicate="duplicateTimeEntry"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
@@ -190,11 +229,17 @@ async function handleDeleteTimeEntry() {
|
||||
<!-- First row: description + duration -->
|
||||
<div class="flex items-center justify-between min-w-0">
|
||||
<TimeEntryDescriptionInput
|
||||
v-if="!isBreak"
|
||||
class="min-w-0 flex-1"
|
||||
:model-value="timeEntry.description"
|
||||
@changed="
|
||||
updateTimeEntryDescription
|
||||
"></TimeEntryDescriptionInput>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 flex-1 truncate text-sm text-text-secondary pl-1.5">
|
||||
{{ timeEntry.description }}
|
||||
</span>
|
||||
<TimeEntryRowDurationInput
|
||||
:start="timeEntry.start"
|
||||
:end="timeEntry.end"
|
||||
@@ -203,7 +248,9 @@ async function handleDeleteTimeEntry() {
|
||||
</div>
|
||||
<!-- Second row: project/task - tags - billable - start - more -->
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<BreakLabel v-if="isBreak" class="pl-1.5 pr-2 min-w-0" />
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-else
|
||||
class="min-w-0"
|
||||
:create-project
|
||||
:create-client
|
||||
@@ -221,21 +268,25 @@ async function handleDeleteTimeEntry() {
|
||||
"></TimeTrackerProjectTaskDropdown>
|
||||
<div class="flex items-center shrink-0">
|
||||
<TimeEntryRowTagDropdown
|
||||
v-if="!isBreak"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
:model-value="timeEntry.tags"
|
||||
compact
|
||||
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-if="!isBreak"
|
||||
:model-value="timeEntry.billable"
|
||||
size="small"
|
||||
@changed="updateTimeEntryBillable"></BillableToggleButton>
|
||||
<TimeTrackerStartStop
|
||||
v-if="canRecreate"
|
||||
:active="!!(timeEntry.start && !timeEntry.end)"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
@changed="onStartStopClick"></TimeTrackerStartStop>
|
||||
<TimeEntryMoreOptionsDropdown
|
||||
:show-duplicate="canRecreate"
|
||||
@edit="handleEdit"
|
||||
@duplicate="duplicateTimeEntry"
|
||||
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
|
||||
@@ -247,7 +298,7 @@ async function handleDeleteTimeEntry() {
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent class="min-w-[160px]">
|
||||
<ContextMenuItem class="space-x-3" @select="onStartStopClick()">
|
||||
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="onStartStopClick()">
|
||||
<PlayIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Continue</span>
|
||||
</ContextMenuItem>
|
||||
@@ -255,7 +306,7 @@ async function handleDeleteTimeEntry() {
|
||||
<PencilIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Edit</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem class="space-x-3" @select="duplicateTimeEntry?.()">
|
||||
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="duplicateTimeEntry?.()">
|
||||
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
|
||||
<span>Duplicate</span>
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -12,11 +12,17 @@ import { CalendarIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
checked: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
checked: boolean;
|
||||
breakDuration?: number;
|
||||
}>(),
|
||||
{
|
||||
breakDuration: 0,
|
||||
}
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
selectAll: [];
|
||||
unselectAll: [];
|
||||
@@ -55,6 +61,19 @@ function selectUnselectAll(value: boolean) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<span
|
||||
v-if="breakDuration > 0"
|
||||
data-testid="day_break_duration"
|
||||
class="text-text-secondary font-normal mr-2">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
breakDuration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
break ·
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
|
||||
73
resources/js/packages/ui/src/TimeEntry/TimeRangeFields.vue
Normal file
73
resources/js/packages/ui/src/TimeEntry/TimeRangeFields.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import { Field, FieldLabel } from '../field';
|
||||
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
|
||||
// Local (user timezone) ISO strings, as produced by getLocalizedDayJs(...).format()
|
||||
const start = defineModel<string>('start', { required: true });
|
||||
const end = defineModel<string>('end', { required: true });
|
||||
|
||||
defineProps<{
|
||||
showHint?: boolean;
|
||||
datePickerSize?: 'sm';
|
||||
}>();
|
||||
|
||||
// Moving the start to or past the end drags the end along, preserving the
|
||||
// range's previous duration, so the range never collapses or inverts.
|
||||
watch(start, (value, oldValue) => {
|
||||
if (getLocalizedDayJs(end.value).isAfter(getLocalizedDayJs(value))) return;
|
||||
const previousDuration = Math.max(
|
||||
0,
|
||||
getLocalizedDayJs(end.value).diff(getLocalizedDayJs(oldValue), 'second')
|
||||
);
|
||||
end.value = getLocalizedDayJs(value).add(previousDuration, 'second').format();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<Field class="col-span-2 sm:col-span-3">
|
||||
<FieldLabel>Duration</FieldLabel>
|
||||
<div class="space-y-2 flex flex-col">
|
||||
<DurationHumanInput
|
||||
v-model:start="start"
|
||||
v-model:end="end"
|
||||
name="Duration"></DurationHumanInput>
|
||||
<div v-if="showHint" class="text-sm flex space-x-1">
|
||||
<InformationCircleIcon
|
||||
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
|
||||
<span class="text-text-secondary text-xs">
|
||||
You can type natural language like
|
||||
<span class="font-semibold"> 2h 30m</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Start</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="start" class="w-full"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="start"
|
||||
:size="datePickerSize"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End</FieldLabel>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TimePickerSimple v-model="end" class="w-full"></TimePickerSimple>
|
||||
<DatePicker
|
||||
v-model="end"
|
||||
:size="datePickerSize"
|
||||
class="w-full"
|
||||
tabindex="1"></DatePicker>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
|
||||
import TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import TimeTrackerEntryInput from '@/packages/ui/src/TimeTracker/TimeTrackerEntryInput.vue';
|
||||
import TimeTrackerProjectControls from '@/packages/ui/src/TimeTracker/TimeTrackerProjectControls.vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
@@ -13,35 +12,45 @@ import type {
|
||||
TimeEntry,
|
||||
Client,
|
||||
} from '@/packages/api/src';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||
import { useSelectEvents } from '@/packages/ui/src/utils/select';
|
||||
import { Coffee, Play } from '@lucide/vue';
|
||||
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
required: true,
|
||||
});
|
||||
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
|
||||
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
timeEntries: TimeEntry[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
timeEntries: TimeEntry[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
isActive: boolean;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
isOnBreak?: boolean;
|
||||
breaksEnabled?: boolean;
|
||||
canResumeAfterBreak?: boolean;
|
||||
resumeDescription?: string | null;
|
||||
timeTrackerMode?: TimeTrackerMode;
|
||||
}>(),
|
||||
{
|
||||
isOnBreak: false,
|
||||
breaksEnabled: false,
|
||||
canResumeAfterBreak: false,
|
||||
resumeDescription: null,
|
||||
timeTrackerMode: 'project',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
startTimer: [];
|
||||
@@ -50,251 +59,136 @@ const emit = defineEmits<{
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
createTimeEntry: [];
|
||||
startBreak: [];
|
||||
resumeAfterBreak: [];
|
||||
}>();
|
||||
|
||||
function updateProject() {
|
||||
setBillableDefaultForProject();
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
|
||||
function setAndStartTimer(timeEntry: TimeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentTimeEntry(timeEntry: TimeEntry) {
|
||||
currentTimeEntry.value.description = timeEntry.description;
|
||||
currentTimeEntry.value.project_id = timeEntry.project_id;
|
||||
currentTimeEntry.value.task_id = timeEntry.task_id;
|
||||
currentTimeEntry.value.tags = timeEntry.tags;
|
||||
currentTimeEntry.value.billable = timeEntry.billable;
|
||||
}
|
||||
|
||||
function startTimerIfNotActive() {
|
||||
if (highlightedDropdownEntryId.value) {
|
||||
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
|
||||
(item) => item.id === highlightedDropdownEntryId.value
|
||||
);
|
||||
if (timeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
showDropdown.value = false;
|
||||
}
|
||||
} else {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
}
|
||||
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
function setBillableDefaultForProject() {
|
||||
const project = props.projects.find(
|
||||
(project) => project.id === currentTimeEntry.value.project_id
|
||||
);
|
||||
if (project) {
|
||||
currentTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
}
|
||||
|
||||
const blockRefocus = ref(false);
|
||||
const entryInput = ref<InstanceType<typeof TimeTrackerEntryInput> | null>(null);
|
||||
|
||||
function onToggleButtonPress(newState: boolean) {
|
||||
if (newState) {
|
||||
emit('startTimer');
|
||||
if (!blockRefocus.value) {
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
}
|
||||
entryInput.value?.focusAfterStart();
|
||||
} else {
|
||||
emit('stopTimer');
|
||||
}
|
||||
}
|
||||
|
||||
const tempDescription = ref(currentTimeEntry.value.description);
|
||||
watch(
|
||||
() => currentTimeEntry.value.description,
|
||||
() => {
|
||||
tempDescription.value = currentTimeEntry.value.description;
|
||||
}
|
||||
);
|
||||
|
||||
function updateTimeEntryDescription() {
|
||||
if (currentTimeEntry.value.description !== tempDescription.value) {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
// Pressing Enter in the range selector starts the timer, same as in the description input.
|
||||
function onRangeEnter() {
|
||||
entryInput.value?.submit();
|
||||
}
|
||||
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
// do not include running time entries
|
||||
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
return (
|
||||
index ===
|
||||
self.findIndex(
|
||||
(t) =>
|
||||
t.description === item.description &&
|
||||
t.task_id === item.task_id &&
|
||||
t.project_id === item.project_id &&
|
||||
t.tags.length === item.tags.length &&
|
||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||
t.billable === item.billable
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// filter time entries based on current description
|
||||
return nonDuplicateTimeEntries
|
||||
.filter((item) => {
|
||||
return item.description
|
||||
?.toLowerCase()
|
||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||
})
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
const showDropdown = ref(false);
|
||||
const { focused } = useFocus(currentTimeEntryDescriptionInput);
|
||||
|
||||
watch(focused, (focused) => {
|
||||
nextTick(() => {
|
||||
// make sure the click event on the dropdown does not get interrupted
|
||||
showDropdown.value = focused;
|
||||
|
||||
// make sure that the input does not get refocused after the dropdown is closed
|
||||
if (!focused) {
|
||||
blockRefocus.value = true;
|
||||
setTimeout(() => {
|
||||
blockRefocus.value = false;
|
||||
}, 100);
|
||||
// After a break ends the tracker returns to the idle input; focus it so a fresh
|
||||
// entry is just type + Enter.
|
||||
watch(
|
||||
() => props.isOnBreak,
|
||||
async (isOnBreak, wasOnBreak) => {
|
||||
if (wasOnBreak && !isOnBreak) {
|
||||
await nextTick();
|
||||
entryInput.value?.focusAfterStart();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(10),
|
||||
shift({
|
||||
limiter: limitShift({
|
||||
offset: 5,
|
||||
}),
|
||||
}),
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'start',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const highlightedDropdownEntryId = ref<string | null>(null);
|
||||
|
||||
useSelectEvents(
|
||||
filteredRecentlyTrackedTimeEntries,
|
||||
highlightedDropdownEntryId,
|
||||
(item) => item.id,
|
||||
showDropdown
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center relative @container" data-testid="dashboard_timer">
|
||||
<div
|
||||
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
|
||||
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg border transition shadow-card"
|
||||
:class="
|
||||
isOnBreak
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: 'bg-card-background border-card-border'
|
||||
">
|
||||
<div class="flex flex-1 items-center relative">
|
||||
<input
|
||||
ref="currentTimeEntryDescriptionInput"
|
||||
v-model="tempDescription"
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
|
||||
type="text"
|
||||
@keydown.enter="startTimerIfNotActive"
|
||||
@keydown.esc="showDropdown = false"
|
||||
@blur="updateTimeEntryDescription" />
|
||||
<div class="@2xl:hidden pr-3 shrink-0">
|
||||
<div
|
||||
v-if="isOnBreak"
|
||||
class="flex w-full items-center gap-2 py-4 sm:py-2.5 px-3.5 @2xl:px-4 text-base font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coffee class="w-5 h-5 shrink-0" />
|
||||
<span>On break</span>
|
||||
</div>
|
||||
<TimeTrackerEntryInput
|
||||
v-else
|
||||
ref="entryInput"
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
:time-entries="timeEntries"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:is-active="isActive"
|
||||
@start-timer="emit('startTimer')"
|
||||
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerEntryInput>
|
||||
<div class="@2xl:hidden pr-3 shrink-0 flex items-center space-x-2">
|
||||
<button
|
||||
v-if="breaksEnabled && !isOnBreak && isActive"
|
||||
type="button"
|
||||
title="Take a break"
|
||||
aria-label="Take a break"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
|
||||
@click="emit('startBreak')">
|
||||
<Coffee class="w-4 h-4" />
|
||||
</button>
|
||||
<TimeTrackerStartStop
|
||||
:active="isActive"
|
||||
:variant="isOnBreak ? 'break' : 'primary'"
|
||||
@changed="onToggleButtonPress"></TimeTrackerStartStop>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
|
||||
ref="floating"
|
||||
class="z-50 w-[min(640px,100vw-2rem)]"
|
||||
:style="floatingStyles">
|
||||
<div
|
||||
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
|
||||
<div
|
||||
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
|
||||
Recently Tracked Time Entries
|
||||
</div>
|
||||
<div class="text-text-secondary py-1 px-1.5">
|
||||
<TimeTrackerRecentlyTrackedEntry
|
||||
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
|
||||
:key="timeEntry.id"
|
||||
:time-entry="timeEntry"
|
||||
:highlighted="highlightedDropdownEntryId === timeEntry.id"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@mousedown="setAndStartTimer(timeEntry)"
|
||||
@mouseenter="
|
||||
highlightedDropdownEntryId = timeEntry.id
|
||||
"></TimeTrackerRecentlyTrackedEntry>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pl-2 shrink min-w-0">
|
||||
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
variant="outline"
|
||||
:create-client
|
||||
:can-create-project
|
||||
:clients
|
||||
:create-project
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-0 @4xl:space-x-2 px-2 @4xl:px-4 shrink-0">
|
||||
<TimeTrackerTagDropdown
|
||||
v-model="currentTimeEntry.tags"
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@changed="$emit('updateTimeEntry')"></TimeTrackerTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-model="currentTimeEntry.billable"
|
||||
@changed="$emit('updateTimeEntry')"></BillableToggleButton>
|
||||
</div>
|
||||
<div class="border-l border-card-border">
|
||||
<TimeTrackerProjectControls
|
||||
v-if="!isOnBreak && timeTrackerMode !== 'simple'"
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients="clients"
|
||||
:create-tag="createTag"
|
||||
:create-project="createProject"
|
||||
:create-client="createClient"
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
:can-create-project="canCreateProject"
|
||||
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerProjectControls>
|
||||
<button
|
||||
v-if="isOnBreak && canResumeAfterBreak"
|
||||
type="button"
|
||||
class="mx-2 flex min-w-0 shrink items-center gap-1.5 h-8 px-3 rounded-md bg-transparent border border-amber-500/40 hover:bg-amber-500/15 text-sm font-medium text-amber-600 dark:text-amber-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 transition"
|
||||
@click="emit('resumeAfterBreak')">
|
||||
<Play class="w-4 h-4 shrink-0" />
|
||||
<span class="truncate">{{
|
||||
resumeDescription ? `Resume "${resumeDescription}"` : 'Resume'
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
class="border-l"
|
||||
:class="isOnBreak ? 'border-amber-500/40' : 'border-card-border'">
|
||||
<TimeTrackerRangeSelector
|
||||
v-model:current-time-entry="currentTimeEntry"
|
||||
v-model:live-timer="liveTimer"
|
||||
:is-on-break="isOnBreak"
|
||||
@start-live-timer="emit('startLiveTimer')"
|
||||
@stop-live-timer="emit('stopLiveTimer')"
|
||||
@update-timer="emit('updateTimeEntry')"
|
||||
@start-timer="emit('startTimer')"
|
||||
@create-time-entry="emit('createTimeEntry')"
|
||||
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
|
||||
@keydown.enter="onRangeEnter"></TimeTrackerRangeSelector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:block">
|
||||
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:flex items-center space-x-3">
|
||||
<button
|
||||
v-if="breaksEnabled && !isOnBreak && isActive"
|
||||
type="button"
|
||||
title="Take a break"
|
||||
aria-label="Take a break"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
|
||||
@click="emit('startBreak')">
|
||||
<Coffee class="w-5 h-5" />
|
||||
</button>
|
||||
<TimeTrackerStartStop
|
||||
:active="isActive"
|
||||
:variant="isOnBreak ? 'break' : 'primary'"
|
||||
size="large"
|
||||
@changed="onToggleButtonPress"></TimeTrackerStartStop>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||
import { useSelectEvents } from '@/packages/ui/src/utils/select';
|
||||
import type { Project, Task, TimeEntry } from '@/packages/api/src';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
timeEntries: TimeEntry[];
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
isActive: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ startTimer: []; updateTimeEntry: [] }>();
|
||||
|
||||
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const tempDescription = ref(currentTimeEntry.value.description);
|
||||
watch(
|
||||
() => currentTimeEntry.value.description,
|
||||
() => {
|
||||
tempDescription.value = currentTimeEntry.value.description;
|
||||
}
|
||||
);
|
||||
|
||||
function updateTimeEntryDescription() {
|
||||
if (currentTimeEntry.value.description !== tempDescription.value) {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentTimeEntry(timeEntry: TimeEntry) {
|
||||
currentTimeEntry.value.description = timeEntry.description;
|
||||
currentTimeEntry.value.project_id = timeEntry.project_id;
|
||||
currentTimeEntry.value.task_id = timeEntry.task_id;
|
||||
currentTimeEntry.value.tags = timeEntry.tags;
|
||||
currentTimeEntry.value.billable = timeEntry.billable;
|
||||
currentTimeEntry.value.type = timeEntry.type;
|
||||
}
|
||||
|
||||
function setAndStartTimer(timeEntry: TimeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the timer from the description input / range selector Enter: picks the highlighted
|
||||
// recently-tracked entry if one is active, otherwise commits the typed description.
|
||||
function submit() {
|
||||
if (highlightedDropdownEntryId.value) {
|
||||
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
|
||||
(item) => item.id === highlightedDropdownEntryId.value
|
||||
);
|
||||
if (timeEntry) {
|
||||
setCurrentTimeEntry(timeEntry);
|
||||
showDropdown.value = false;
|
||||
}
|
||||
} else {
|
||||
currentTimeEntry.value.description = tempDescription.value;
|
||||
}
|
||||
|
||||
if (!props.isActive) {
|
||||
emit('startTimer');
|
||||
} else {
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
// do not include running time entries and breaks (breaks are started via the break button)
|
||||
const finishedTimeEntries = props.timeEntries.filter(
|
||||
(item) => item.end !== null && item.type !== 'break'
|
||||
);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
return (
|
||||
index ===
|
||||
self.findIndex(
|
||||
(t) =>
|
||||
t.description === item.description &&
|
||||
t.task_id === item.task_id &&
|
||||
t.project_id === item.project_id &&
|
||||
t.tags.length === item.tags.length &&
|
||||
t.tags.every((tag) => item.tags.includes(tag)) &&
|
||||
t.billable === item.billable
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// filter time entries based on current description
|
||||
return nonDuplicateTimeEntries
|
||||
.filter((item) => {
|
||||
return item.description
|
||||
?.toLowerCase()
|
||||
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
|
||||
})
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
const showDropdown = ref(false);
|
||||
const blockRefocus = ref(false);
|
||||
const { focused } = useFocus(currentTimeEntryDescriptionInput);
|
||||
|
||||
watch(focused, (focused) => {
|
||||
nextTick(() => {
|
||||
// make sure the click event on the dropdown does not get interrupted
|
||||
showDropdown.value = focused;
|
||||
|
||||
// make sure that the input does not get refocused after the dropdown is closed
|
||||
if (!focused) {
|
||||
blockRefocus.value = true;
|
||||
setTimeout(() => {
|
||||
blockRefocus.value = false;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const floating = ref(null);
|
||||
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(10),
|
||||
shift({
|
||||
limiter: limitShift({
|
||||
offset: 5,
|
||||
}),
|
||||
}),
|
||||
flip({
|
||||
fallbackAxisSideDirection: 'start',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const highlightedDropdownEntryId = ref<string | null>(null);
|
||||
|
||||
useSelectEvents(
|
||||
filteredRecentlyTrackedTimeEntries,
|
||||
highlightedDropdownEntryId,
|
||||
(item) => item.id,
|
||||
showDropdown
|
||||
);
|
||||
|
||||
// Called by the shell after the start/stop button starts a timer, so typing can continue.
|
||||
function focusAfterStart() {
|
||||
if (!blockRefocus.value) {
|
||||
currentTimeEntryDescriptionInput.value?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ submit, focusAfterStart });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="currentTimeEntryDescriptionInput"
|
||||
v-model="tempDescription"
|
||||
placeholder="What are you working on?"
|
||||
data-testid="time_entry_description"
|
||||
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
|
||||
type="text"
|
||||
@keydown.enter="submit"
|
||||
@keydown.esc="showDropdown = false"
|
||||
@blur="updateTimeEntryDescription" />
|
||||
<div
|
||||
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
|
||||
ref="floating"
|
||||
class="z-50 w-[min(640px,100vw-2rem)]"
|
||||
:style="floatingStyles">
|
||||
<div
|
||||
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
|
||||
<div
|
||||
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
|
||||
Recently Tracked Time Entries
|
||||
</div>
|
||||
<div class="text-text-secondary py-1 px-1.5">
|
||||
<TimeTrackerRecentlyTrackedEntry
|
||||
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
|
||||
:key="timeEntry.id"
|
||||
:time-entry="timeEntry"
|
||||
:highlighted="highlightedDropdownEntryId === timeEntry.id"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@mousedown="setAndStartTimer(timeEntry)"
|
||||
@mouseenter="
|
||||
highlightedDropdownEntryId = timeEntry.id
|
||||
"></TimeTrackerRecentlyTrackedEntry>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XMarkIcon } from '@heroicons/vue/20/solid';
|
||||
import { PlusIcon, XMarkIcon, ClockIcon } from '@heroicons/vue/20/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '..';
|
||||
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
|
||||
|
||||
const props = defineProps<{
|
||||
hasActiveTimer: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hasActiveTimer: boolean;
|
||||
timeTrackerMode?: TimeTrackerMode;
|
||||
breaksEnabled?: boolean;
|
||||
isOnBreak?: boolean;
|
||||
}>(),
|
||||
{
|
||||
timeTrackerMode: 'project',
|
||||
breaksEnabled: false,
|
||||
isOnBreak: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
manualEntry: [];
|
||||
startBreak: [];
|
||||
discard: [];
|
||||
toggleTimeTrackerMode: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -39,6 +53,23 @@ const emit = defineEmits<{
|
||||
<PlusIcon class="w-5" />
|
||||
<span>Manual time entry</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.breaksEnabled && !props.isOnBreak"
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('startBreak')">
|
||||
<Coffee class="w-5" />
|
||||
<span>Start Break</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="flex items-center space-x-3 cursor-pointer"
|
||||
@click="emit('toggleTimeTrackerMode')">
|
||||
<ClockIcon class="w-5" />
|
||||
<span>{{
|
||||
props.timeTrackerMode === 'simple'
|
||||
? 'Switch to project mode'
|
||||
: 'Switch to simple mode'
|
||||
}}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-if="props.hasActiveTimer"
|
||||
class="flex items-center space-x-3 cursor-pointer text-destructive focus:text-destructive"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import TimeTrackerProjectControls from './TimeTrackerProjectControls.vue';
|
||||
import TimeTrackerProjectTaskDropdown from './TimeTrackerProjectTaskDropdown.vue';
|
||||
import type { Project, TimeEntry } from '@/packages/api/src';
|
||||
|
||||
function timeEntry(overrides: Partial<TimeEntry> = {}): TimeEntry {
|
||||
return {
|
||||
id: 'te-1',
|
||||
description: '',
|
||||
start: '2026-07-14T09:00:00Z',
|
||||
end: null,
|
||||
duration: null,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
organization_id: 'org-1',
|
||||
user_id: 'user-1',
|
||||
tags: [],
|
||||
billable: false,
|
||||
type: 'work',
|
||||
...overrides,
|
||||
} as TimeEntry;
|
||||
}
|
||||
|
||||
describe('TimeTrackerProjectControls', () => {
|
||||
it('adopts the billable default of a newly selected project', async () => {
|
||||
const current = timeEntry({ project_id: null, billable: false });
|
||||
const billableProject = { id: 'p-1', is_billable: true } as Project;
|
||||
const wrapper = shallowMount(TimeTrackerProjectControls, {
|
||||
props: {
|
||||
currentTimeEntry: current,
|
||||
projects: [billableProject],
|
||||
tasks: [],
|
||||
tags: [],
|
||||
clients: [],
|
||||
createTag: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
createClient: vi.fn(),
|
||||
currency: 'EUR',
|
||||
organizationBillableRate: null,
|
||||
enableEstimatedTime: false,
|
||||
canCreateProject: false,
|
||||
},
|
||||
});
|
||||
|
||||
const dropdown = wrapper.findComponent(TimeTrackerProjectTaskDropdown);
|
||||
// The dropdown sets the project via v-model, then emits `changed`.
|
||||
dropdown.vm.$emit('update:project', 'p-1');
|
||||
dropdown.vm.$emit('changed');
|
||||
await nextTick();
|
||||
|
||||
expect(current.billable).toBe(true);
|
||||
expect(wrapper.emitted('updateTimeEntry')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
|
||||
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
|
||||
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
|
||||
import type {
|
||||
Client,
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
Project,
|
||||
Tag,
|
||||
Task,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
|
||||
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
currency: string;
|
||||
organizationBillableRate: number | null;
|
||||
enableEstimatedTime: boolean;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ updateTimeEntry: [] }>();
|
||||
|
||||
function updateProject() {
|
||||
// Adopt the project's billable default when a project is picked.
|
||||
const project = props.projects.find((p) => p.id === currentTimeEntry.value.project_id);
|
||||
if (project) {
|
||||
currentTimeEntry.value.billable = project.is_billable;
|
||||
}
|
||||
emit('updateTimeEntry');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
|
||||
<TimeTrackerProjectTaskDropdown
|
||||
v-model:project="currentTimeEntry.project_id"
|
||||
v-model:task="currentTimeEntry.task_id"
|
||||
variant="outline"
|
||||
:create-client="createClient"
|
||||
:can-create-project="canCreateProject"
|
||||
:clients="clients"
|
||||
:create-project="createProject"
|
||||
:currency="currency"
|
||||
:organization-billable-rate="organizationBillableRate"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:enable-estimated-time="enableEstimatedTime"
|
||||
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center space-x-0 @4xl:space-x-2 px-2 @4xl:px-4 shrink-0">
|
||||
<TimeTrackerTagDropdown
|
||||
v-model="currentTimeEntry.tags"
|
||||
:create-tag="createTag"
|
||||
:tags="tags"
|
||||
@changed="emit('updateTimeEntry')"></TimeTrackerTagDropdown>
|
||||
<BillableToggleButton
|
||||
v-model="currentTimeEntry.billable"
|
||||
@changed="emit('updateTimeEntry')"></BillableToggleButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -694,8 +694,8 @@ const showCreateProject = ref(false);
|
||||
class="flex items-center space-x-2 w-full px-5 py-1.5 text-start text-xs font-semibold leading-5 text-text-primary focus:outline-none transition duration-150 ease-in-out"
|
||||
:class="
|
||||
row.task.id === highlightedItemId
|
||||
? 'bg-card-background-active'
|
||||
: 'bg-quaternary'
|
||||
? 'bg-quaternary dark:bg-tertiary'
|
||||
: 'bg-tertiary dark:bg-quaternary'
|
||||
"
|
||||
@click="selectTask(row.task.id)"
|
||||
@mouseenter="setHighlightItemId(row.task.id)">
|
||||
|
||||
@@ -11,6 +11,15 @@ const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
|
||||
});
|
||||
const now = defineModel<null | Dayjs>('liveTimer');
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
isOnBreak?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isOnBreak: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
startLiveTimer: [];
|
||||
stopLiveTimer: [];
|
||||
@@ -154,7 +163,12 @@ function closeAndFocusInput() {
|
||||
v-model="currentTime"
|
||||
placeholder="00:00:00"
|
||||
data-testid="time_entry_time"
|
||||
class="w-[110px] lg:w-[120px] h-full text-text-primary py-2.5 rounded-lg border-border-secondary border text-center px-4 text-base font-semibold bg-card-background border-none placeholder-text-tertiary focus:ring-0 transition"
|
||||
class="w-[110px] lg:w-[120px] h-full py-2.5 rounded-lg text-center px-4 text-base font-semibold placeholder-text-tertiary focus:ring-0 transition"
|
||||
:class="
|
||||
isOnBreak
|
||||
? 'text-amber-600 dark:text-amber-400 bg-transparent border-none'
|
||||
: 'text-text-primary bg-card-background border-border-secondary border border-none'
|
||||
"
|
||||
type="text"
|
||||
@focusin="openModalOnTab"
|
||||
@click="openModalOnClick"
|
||||
|
||||
6
resources/js/packages/ui/src/TimeTracker/types.ts
Normal file
6
resources/js/packages/ui/src/TimeTracker/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* How the time tracker presents its controls. `project` shows the full
|
||||
* project/task/tag/billable controls; `simple` hides them for plain
|
||||
* description-only tracking. Persisted client-side as a UI preference.
|
||||
*/
|
||||
export type TimeTrackerMode = 'project' | 'simple';
|
||||
@@ -11,6 +11,7 @@ const timeTrackerVariants = cva(
|
||||
'text-white ring-accent-200/10 focus-visible:ring-ring focus-visible:ring-2 ring-4 sm:ring-[6px]',
|
||||
secondary:
|
||||
'bg-quaternary text-text-tertiary hover:text-text-primary focus:ring-2 focus:ring-border-tertiary',
|
||||
break: 'text-white ring-amber-200/10 focus-visible:ring-ring focus-visible:ring-2 ring-4 sm:ring-[6px]',
|
||||
},
|
||||
size: {
|
||||
small: 'w-6 h-6',
|
||||
@@ -33,6 +34,16 @@ const timeTrackerVariants = cva(
|
||||
active: false,
|
||||
class: 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-700',
|
||||
},
|
||||
{
|
||||
variant: 'break',
|
||||
active: true,
|
||||
class: 'bg-amber-500/80 hover:bg-amber-600/80 focus:bg-amber-600/80',
|
||||
},
|
||||
{
|
||||
variant: 'break',
|
||||
active: false,
|
||||
class: 'bg-accent-300/70 hover:bg-accent-400/70 focus:bg-accent-700',
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
|
||||
57
resources/js/packages/ui/src/utils/breakPlacement.test.ts
Normal file
57
resources/js/packages/ui/src/utils/breakPlacement.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import {
|
||||
findMisplacedBreak,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
// Decision logic behind the aggregate (collapsed grouped-break) row's placement
|
||||
// warning: the row shows the hint — and navigates the calendar — based on the
|
||||
// first misplaced break in the group.
|
||||
function breakEntry(id: string): TimeEntry {
|
||||
return { id, type: 'break', start: '2026-07-14T10:00:00Z' } as TimeEntry;
|
||||
}
|
||||
|
||||
function hint(misplaced: boolean): BreakPlacementHint {
|
||||
return {
|
||||
misplaced,
|
||||
previousWorkEnd: null,
|
||||
nextWorkStart: null,
|
||||
gapBeforeSeconds: null,
|
||||
gapAfterSeconds: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findMisplacedBreak', () => {
|
||||
it('returns the first misplaced break in a group', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(true),
|
||||
});
|
||||
expect(result?.id).toBe('break-b');
|
||||
});
|
||||
|
||||
it('returns null when no break in the group is misplaced', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(false),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the group has no placement hints', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
expect(findMisplacedBreak(entries, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores hints for entries that are not in the group', () => {
|
||||
const entries = [breakEntry('break-a')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-elsewhere': hint(true),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
102
resources/js/packages/ui/src/utils/breakPlacement.ts
Normal file
102
resources/js/packages/ui/src/utils/breakPlacement.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
|
||||
export interface BreakPlacementHint {
|
||||
misplaced: boolean;
|
||||
// Closest work end at or before the break start (null if none is known)
|
||||
previousWorkEnd: string | null;
|
||||
// Closest work start at or after the break end (null if none is known)
|
||||
nextWorkStart: string | null;
|
||||
gapBeforeSeconds: number | null;
|
||||
gapAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
// How far a break may sit from the nearest work entry before it gets a placement hint
|
||||
export const BREAK_GAP_TOLERANCE_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* Grouped breaks collapse into a single summary row, so the row needs to know
|
||||
* whether any break in the group is misplaced (to show the warning) and which
|
||||
* one to navigate to (all grouped entries share the same day). Returns the first
|
||||
* misplaced break in the group, or null when none is flagged.
|
||||
*/
|
||||
export function findMisplacedBreak(
|
||||
entries: TimeEntry[],
|
||||
hints: Record<string, BreakPlacementHint | null>
|
||||
): TimeEntry | null {
|
||||
return entries.find((entry) => hints[entry.id]?.misplaced) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A break only means something between work. This computes how far a break
|
||||
* sits from the nearest work entry on either side — everything non-work
|
||||
* (untracked gaps and other breaks) counts as distance. If either side has
|
||||
* more than the tolerated gap (or no work at all), the break gets a hint.
|
||||
*
|
||||
* Non-blocking by design: placement can not be validated at write time
|
||||
* (it depends on entries that may not exist yet), so it is derived at read
|
||||
* time from the loaded entries.
|
||||
*/
|
||||
export function getBreakPlacementHint(
|
||||
breakEntry: TimeEntry,
|
||||
allEntries: TimeEntry[]
|
||||
): BreakPlacementHint | null {
|
||||
if (breakEntry.type !== 'break') {
|
||||
return null;
|
||||
}
|
||||
const dayjs = getDayJsInstance();
|
||||
const breakStart = dayjs.utc(breakEntry.start);
|
||||
const breakEnd = breakEntry.end === null ? dayjs.utc() : dayjs.utc(breakEntry.end);
|
||||
const toleranceSeconds = BREAK_GAP_TOLERANCE_MINUTES * 60;
|
||||
|
||||
let previousWorkEnd: ReturnType<typeof dayjs> | null = null;
|
||||
let nextWorkStart: ReturnType<typeof dayjs> | null = null;
|
||||
|
||||
for (const entry of allEntries) {
|
||||
if (entry.type === 'break' || entry.id === breakEntry.id) {
|
||||
continue;
|
||||
}
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
const entryEnd = entry.end === null ? dayjs.utc() : dayjs.utc(entry.end);
|
||||
|
||||
// Work overlapping the break counts as touching on both sides
|
||||
if (entryEnd.isAfter(breakStart) && entryStart.isBefore(breakEnd)) {
|
||||
return {
|
||||
misplaced: false,
|
||||
previousWorkEnd: entryEnd.format(),
|
||||
nextWorkStart: entryStart.format(),
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: 0,
|
||||
};
|
||||
}
|
||||
if (!entryEnd.isAfter(breakStart)) {
|
||||
if (previousWorkEnd === null || entryEnd.isAfter(previousWorkEnd)) {
|
||||
previousWorkEnd = entryEnd;
|
||||
}
|
||||
}
|
||||
if (!entryStart.isBefore(breakEnd)) {
|
||||
if (nextWorkStart === null || entryStart.isBefore(nextWorkStart)) {
|
||||
nextWorkStart = entryStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gapBeforeSeconds =
|
||||
previousWorkEnd !== null ? breakStart.diff(previousWorkEnd, 'second') : null;
|
||||
const gapAfterSeconds = nextWorkStart !== null ? nextWorkStart.diff(breakEnd, 'second') : null;
|
||||
|
||||
// A running break has no "after" side yet — only judge the before side
|
||||
const isRunning = breakEntry.end === null;
|
||||
|
||||
const beforeMisplaced = gapBeforeSeconds === null || gapBeforeSeconds > toleranceSeconds;
|
||||
const afterMisplaced =
|
||||
!isRunning && (gapAfterSeconds === null || gapAfterSeconds > toleranceSeconds);
|
||||
|
||||
return {
|
||||
misplaced: beforeMisplaced || afterMisplaced,
|
||||
previousWorkEnd: previousWorkEnd?.format() ?? null,
|
||||
nextWorkStart: nextWorkStart?.format() ?? null,
|
||||
gapBeforeSeconds,
|
||||
gapAfterSeconds,
|
||||
};
|
||||
}
|
||||
19
resources/js/packages/ui/src/utils/useBreaksEnabled.ts
Normal file
19
resources/js/packages/ui/src/utils/useBreaksEnabled.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { computed, inject, type ComputedRef } from 'vue';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
/**
|
||||
* Whether break tracking is enabled for the current organization.
|
||||
*
|
||||
* Components below the app layout can call this with no argument (the layout
|
||||
* provides `organization`); pages that sit above the layout pass their own
|
||||
* organization ref. Without an organization (e.g. public report views) breaks
|
||||
* count as disabled.
|
||||
*/
|
||||
export function useBreaksEnabled(organization?: {
|
||||
value: Organization | undefined | null;
|
||||
}): ComputedRef<boolean> {
|
||||
const org =
|
||||
organization ??
|
||||
inject<ComputedRef<Organization | undefined> | undefined>('organization', undefined);
|
||||
return computed(() => org?.value?.breaks_enabled ?? false);
|
||||
}
|
||||
@@ -88,8 +88,8 @@
|
||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
--theme-color-row-background: var(--theme-color-primary);
|
||||
--theme-color-row-heading-background: var(--theme-color-primary);
|
||||
--theme-color-row-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||
--theme-color-icon-default: var(--color-text-quaternary);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user