add calendar view

This commit is contained in:
Gregor Vostrak
2025-08-14 16:14:21 +02:00
parent 04c44097d0
commit 9fa9522237
14 changed files with 1266 additions and 35 deletions

View File

@@ -10,7 +10,8 @@ const props = withDefaults(
icon?: Component;
size?: 'small' | 'base';
loading?: boolean;
class?: string;
// Accept any valid Vue class binding shape (string | object | array)
class?: Parameters<typeof twMerge>[0];
}>(),
{
type: 'button',

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { formatDate, formatHumanReadableDuration } from '../utils/time';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
date: Date;
totalMinutes?: number;
}>();
const totalSeconds = computed(() => (props.totalMinutes ?? 0) * 60);
// Injected organization for formatting settings
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format);
const dateFormat = computed(() => organization?.value?.date_format);
</script>
<template>
<div class="fc-day-header-custom">
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
<span class="block text-xs text-muted-foreground font-medium mt-1">
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
</span>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
title: string;
projectName?: string | null;
taskName?: string | null;
clientName?: string | null;
durationSeconds?: number;
start?: string | Date | null;
end?: string | Date | null;
}>();
const effectiveDurationSeconds = computed(() => {
if (typeof props.durationSeconds === 'number') {
return props.durationSeconds;
}
if (props.start && props.end) {
const end = getDayJsInstance()(props.end as unknown as string | Date);
const start = getDayJsInstance()(props.start as unknown as string | Date);
const minutes = end.diff(start, 'minutes');
return minutes * 60;
}
return 0;
});
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format);
const formattedDuration = computed(() =>
formatHumanReadableDuration(
effectiveDurationSeconds.value,
intervalFormat.value,
numberFormat.value
)
);
</script>
<template>
<div class="text-xs leading-tight">
<div class="font-semibold mb-0.5">{{ title }}</div>
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
{{ projectName }}
</div>
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
{{ taskName }}
</div>
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
{{ clientName }}
</div>
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
{{ formattedDuration }}
</div>
</div>
</template>

View File

@@ -0,0 +1,540 @@
<script setup lang="ts">
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
import { computed, ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import CalendarEventContent from './CalendarEventContent.vue';
import CalendarDayHeader from './CalendarDayHeader.vue';
import type {
TimeEntry,
Project,
Client,
Task,
CreateProjectBody,
CreateClientBody,
Tag,
} from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
(e: 'refresh'): void;
}>();
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
loading?: boolean;
// Permissions / feature flags
enableEstimatedTime: boolean;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
) => Promise<void>;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
}>();
// Local component state
const newEventStart = ref<Dayjs | null>(null);
const newEventEnd = ref<Dayjs | null>(null);
const showCreateTimeEntryModal = ref<boolean>(false);
const showEditTimeEntryModal = ref<boolean>(false);
const selectedTimeEntry = ref<TimeEntry | null>(null);
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Map API entries to FullCalendar events
const events = computed(() => {
return props.timeEntries
?.filter((timeEntry) => timeEntry.end !== null)
?.map((timeEntry) => {
const project = props.projects.find((p) => p.id === timeEntry.project_id);
const client = props.clients.find((c) => c.id === project?.client_id);
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
const duration = getDayJsInstance()(timeEntry.end!).diff(
getDayJsInstance()(timeEntry.start),
'minutes'
);
const title = timeEntry.description || 'No description';
return {
id: timeEntry.id,
start: getLocalizedDayJs(timeEntry.start).format(),
end: getLocalizedDayJs(timeEntry.end!).format(),
title,
backgroundColor: project?.color || '#6B7280',
borderColor: project?.color || '#6B7280',
textColor: '#FFFFFF',
extendedProps: {
timeEntry,
project,
client,
task,
duration,
},
};
});
});
// Daily totals used in day header
const dailyTotals = computed(() => {
const totals: Record<string, number> = {};
props.timeEntries
.filter((entry) => entry.end !== null)
.forEach((entry) => {
const date = getDayJsInstance()(entry.start).format('YYYY-MM-DD');
const duration = getDayJsInstance()(entry.end!).diff(
getDayJsInstance()(entry.start),
'minutes'
);
totals[date] = (totals[date] || 0) + duration;
});
return totals;
});
function emitDatesChange(arg: DatesSetArg) {
emit('dates-change', { start: arg.start, end: arg.end });
}
function handleDateSelect(arg: { start: Date; end: Date }) {
const startTime = getDayJsInstance()(arg.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc();
let endTime = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true).utc();
const timeDiff = endTime.diff(startTime, 'minutes');
if (timeDiff <= 15) {
endTime = startTime.clone().add(1, 'hour');
}
newEventStart.value = startTime;
newEventEnd.value = endTime;
showCreateTimeEntryModal.value = true;
}
function handleEventClick(arg: EventClickArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps;
selectedTimeEntry.value = ext.timeEntry;
showEditTimeEntryModal.value = true;
}
async function handleEventDrop(arg: EventDropArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return;
const updatedTimeEntry = {
...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
async function handleEventResize(arg: EventChangeArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps;
const timeEntry = ext.timeEntry;
if (!arg.event.start || !arg.event.end) return;
const updatedTimeEntry = {
...timeEntry,
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
.utc()
.format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'timeGridWeek,timeGridDay',
},
height: '100vh',
slotMinTime: '00:00:00',
slotMaxTime: '24:00:00',
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
snapDuration: '00:15:00',
allDaySlot: false,
nowIndicator: true,
selectable: true,
selectMirror: true,
editable: true,
eventResizableFromStart: true,
eventDurationEditable: true,
timeZone: 'America/Adak',
eventStartEditable: true,
select: handleDateSelect,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
datesSet: emitDatesChange,
events: events.value,
}));
watch(showCreateTimeEntryModal, (value) => {
if (!value) {
newEventStart.value = null;
newEventEnd.value = null;
emit('refresh');
}
});
watch(showEditTimeEntryModal, (value) => {
if (!value) {
selectedTimeEntry.value = null;
emit('refresh');
}
});
</script>
<template>
<div class="h-screen w-full relative">
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="flex flex-col items-center space-y-4">
<LoadingSpinner class="h-8 w-8" />
<p class="text-muted-foreground">Loading calendar data...</p>
</div>
</div>
<TimeEntryCreateModal
v-model:show="showCreateTimeEntryModal"
:enable-estimated-time="enableEstimatedTime"
:create-time-entry="createTimeEntry"
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:tags="tags as any"
:projects="projects"
:tasks="tasks"
:clients="clients"
:start="newEventStart ? newEventStart.toISOString() : undefined"
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
<TimeEntryEditModal
v-model:show="showEditTimeEntryModal"
:time-entry="selectedTimeEntry as any"
:enable-estimated-time="enableEstimatedTime"
:update-time-entry="updateTimeEntry"
:delete-time-entry="deleteTimeEntry"
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:tags="tags as any"
:projects="projects"
:tasks="tasks"
:clients="clients" />
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
<template #eventContent="arg">
<CalendarEventContent
:title="arg.event.title"
:project-name="(arg.event.extendedProps as any).project?.name"
:task-name="(arg.event.extendedProps as any).task?.name"
:client-name="(arg.event.extendedProps as any).client?.name"
:duration-seconds="
((arg.event.extendedProps as any).duration ?? undefined)
? (arg.event.extendedProps as any).duration * 60
: undefined
"
:start="arg.event.start as any"
:end="arg.event.end as any" />
</template>
<template #dayHeaderContent="arg">
<CalendarDayHeader
:date="arg.date"
:total-minutes="
dailyTotals[getDayJsInstance()(arg.date).format('YYYY-MM-DD')] || 0
" />
</template>
</FullCalendar>
</div>
</template>
<style scoped>
.fullcalendar {
height: 100%;
--fc-border-color: var(--border);
}
/* FullCalendar theme customization */
.fullcalendar :deep(.fc) {
background-color: var(--theme-color-default-background);
color: var(--foreground);
font-family: inherit;
}
.fullcalendar :deep(.fc-timegrid-slot) {
height: 25px;
transition: height 0.2s ease;
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-toolbar) {
background-color: var(--theme-color-default-background);
padding: 0.5rem;
margin-bottom: 0;
}
.fullcalendar :deep(.fc-toolbar-title) {
color: var(--foreground);
font-size: 1rem;
font-weight: 600;
}
.fullcalendar :deep(.fc-button) {
background-color: var(--secondary);
border: 1px solid var(--border);
color: var(--foreground);
font-weight: 500;
font-size: 14px !important;
}
.fullcalendar :deep(.fc-button:hover) {
background-color: var(--muted);
border-color: var(--border);
}
.fullcalendar :deep(.fc-button:focus) {
box-shadow: 0 0 0 2px var(--ring);
}
.fullcalendar :deep(.fc-button-active) {
background-color: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.fullcalendar :deep(.fc-col-header) {
border-bottom: 1px solid var(--border);
}
.fullcalendar :deep(.fc-col-header-cell) {
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0.75rem 0.5rem;
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-timegrid-axis) {
background-color: var(--theme-color-default-background) !important;
}
.fullcalendar :deep(.fc-col-header-cell .fc-col-header-cell-cushion) {
padding: 0;
}
.fullcalendar :deep(.fc-timegrid-axis) {
background-color: var(--theme-color-default-background);
border-right: 1px solid var(--border);
}
/* Quarter-hour slots - transparent borders */
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-label) {
border-top: 1px solid transparent;
}
.fullcalendar :deep(.fc-timegrid-slot-minor.fc-timegrid-slot-lane) {
--tw-border-opacity: 0;
}
.fullcalendar :deep(.fc-day-today.fc-col-header-cell) {
background-color: var(--color-accent-default);
}
.fullcalendar :deep(.fc-day-today) {
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-now-indicator) {
border-color: var(--primary);
border-width: 2px;
}
.fullcalendar :deep(.fc-event) {
border: 1px solid !important;
border-radius: var(--radius);
padding: 0.45rem 0.25rem;
font-size: 0.75rem;
cursor: pointer;
box-shadow: var(--theme-shadow-card);
opacity: 0.9;
}
.fullcalendar :deep(.fc-v-event) {
background-color: var(--muted);
border-color: var(--muted);
}
.fullcalendar :deep(.fc-event-title) {
font-weight: 500;
line-height: 1.2;
}
/* Enhanced FullCalendar resize handles */
.fullcalendar :deep(.fc-event-resizer) {
position: absolute;
z-index: 99;
background: '#FFF';
border-radius: 2px;
width: 100%;
height: 4px;
left: 0;
transition: all 0.2s ease;
opacity: 0;
}
.fullcalendar :deep(.fc-event-resizer-start) {
top: -2px;
cursor: n-resize;
}
.fullcalendar :deep(.fc-event-resizer-end) {
bottom: -2px;
cursor: s-resize;
}
.fullcalendar :deep(.fc-event:hover .fc-event-resizer) {
opacity: 1;
}
.fullcalendar :deep(.fc-event-resizer:hover) {
background: '#FFF';
height: 6px;
}
/* Update the earlier hover rule to include the shadow */
.fullcalendar :deep(.fc-event:hover) {
opacity: 1;
transition: all 0.2s ease;
box-shadow: var(--theme-shadow-dropdown);
}
.fullcalendar :deep(.fc-timegrid-event-harness) {
margin: 0 1px;
}
.fullcalendar :deep(.fc-highlight) {
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-select-mirror) {
background-color: var(--accent);
border: 1px solid var(--primary);
}
.fullcalendar :deep(.fc-scrollgrid) {
border: 1px solid var(--border);
border-left: 1px solid transparent;
}
.fullcalendar :deep(.fc-scrollgrid-section > td) {
border-right: 1px solid var(--border);
}
.fullcalendar :deep(.fc-timegrid-body) {
background-color: var(--theme-color-default-background);
}
.fullcalendar :deep(.fc-timegrid-col) {
border-right: 1px solid var(--border);
}
.fullcalendar :deep(.fc-timegrid-axis-cushion) {
color: var(--theme-text-secondary);
font-size: 0.75rem;
font-weight: 500;
}
.fullcalendar :deep(.fc-timegrid-slot-label-cushion) {
font-size: 0.8125rem;
color: var(--muted-foreground);
}
.fullcalendar :deep(.fc-col-header-cell-cushion) {
color: var(--foreground);
font-size: 0.875rem;
font-weight: 600;
}
/* Daily totals styling */
.fullcalendar :deep(.fc-col-header-cell .text-muted-foreground) {
color: var(--muted-foreground);
margin-top: 0.125rem;
}
/* Reduce visibility of time slot dividers */
.fullcalendar :deep(.fc-timegrid-divider) {
display: none;
}
/* Make scrollbars gray */
.fullcalendar :deep(.fc-scroller) {
scrollbar-width: thin;
scrollbar-color: var(--muted-foreground) transparent;
}
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar) {
width: 8px;
}
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-track) {
background: transparent;
}
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb) {
background-color: var(--muted-foreground);
border-radius: 4px;
}
.fullcalendar :deep(.fc-scroller::-webkit-scrollbar-thumb:hover) {
background-color: var(--foreground);
}
/* Improve time axis styling */
.fullcalendar :deep(.fc-timegrid-axis-chunk) {
background-color: var(--theme-color-default-background);
}
/* Simple event main styling */
.fullcalendar :deep(.fc-event-main) {
padding: 0.125rem 0.25rem;
}
</style>

View File

@@ -41,6 +41,8 @@ const props = defineProps<{
projects: Project[];
tasks: Task[];
clients: Client[];
start?: string;
end?: string;
}>();
const description = ref<HTMLInputElement | null>(null);
@@ -63,7 +65,27 @@ const timeEntryDefaultValues = {
end: getDayJsInstance().utc().format(),
};
const timeEntry = ref({ ...timeEntryDefaultValues });
const timeEntry = ref({
...timeEntryDefaultValues,
});
// update the localStart and localEnd when props.start or props.end get updates
watch(
() => props.start,
(value) => {
if (value) {
localStart.value = getLocalizedDayJs(value).format();
}
}
);
watch(
() => props.end,
(value) => {
if (value) {
localEnd.value = getLocalizedDayJs(value).format();
}
}
);
watch(
() => timeEntry.value.project_id,

View File

@@ -0,0 +1,311 @@
<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, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { TagIcon } from '@heroicons/vue/20/solid';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Client,
TimeEntry,
} from '@/packages/api/src';
import { getOrganizationCurrencyString } from '@/utils/money';
import { canCreateProjects } from '@/utils/permissions';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { Badge } from '@/packages/ui/src';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import SelectDropdown from '@/packages/ui/src/Input/SelectDropdown.vue';
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 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);
const deleting = ref(false);
const props = defineProps<{
timeEntry: TimeEntry | null;
enableEstimatedTime: boolean;
updateTimeEntry: (entry: TimeEntry) => Promise<void>;
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
tags: Tag[];
projects: Project[];
tasks: Task[];
clients: Client[];
}>();
const description = ref<HTMLInputElement | null>(null);
watch(show, (value) => {
if (value) {
nextTick(() => {
description.value?.focus();
});
}
});
const editableTimeEntry = ref<TimeEntry | null>(null);
watch(
() => props.timeEntry,
(newTimeEntry) => {
if (newTimeEntry) {
editableTimeEntry.value = { ...newTimeEntry };
}
},
{ immediate: true }
);
watch(
() => editableTimeEntry.value?.project_id,
(value) => {
if (value && editableTimeEntry.value) {
// check if project is billable by default and set billable accordingly
const project = props.projects.find((p) => p.id === value);
if (project) {
editableTimeEntry.value.billable = project.is_billable;
}
}
}
);
const localStart = computed({
get: () =>
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.start).format() : '',
set: (value: string) => {
if (editableTimeEntry.value) {
editableTimeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
}
},
});
const localEnd = computed({
get: () =>
editableTimeEntry.value ? getLocalizedDayJs(editableTimeEntry.value.end).format() : '',
set: (value: string) => {
if (editableTimeEntry.value) {
editableTimeEntry.value.end = getLocalizedDayJs(value).utc().format();
}
},
});
async function submit() {
if (editableTimeEntry.value) {
saving.value = true;
try {
await props.updateTimeEntry(editableTimeEntry.value);
show.value = false;
} finally {
saving.value = false;
}
}
}
async function deleteEntry() {
if (editableTimeEntry.value) {
deleting.value = true;
try {
await props.deleteTimeEntry(editableTimeEntry.value.id);
show.value = false;
} finally {
deleting.value = false;
}
}
}
const billableProxy = computed({
get: () =>
editableTimeEntry.value ? (editableTimeEntry.value.billable ? 'true' : 'false') : 'false',
set: (value: string) => {
if (editableTimeEntry.value) {
editableTimeEntry.value.billable = value === 'true';
}
},
});
type BillableOption = {
label: string;
value: string;
};
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Edit time entry </span>
</div>
</template>
<template #content>
<div v-if="editableTimeEntry" class="space-y-4">
<div class="sm:flex items-end space-y-2 sm:space-y-0 sm:space-x-4">
<div class="flex-1">
<TextInput
id="description"
ref="description"
v-model="editableTimeEntry.description"
placeholder="What did you work on?"
type="text"
class="mt-1 block w-full"
@keydown.enter="submit" />
</div>
</div>
<div
class="sm:flex justify-between items-end space-y-2 sm:space-y-0 pt-4 sm:space-x-4">
<div class="flex w-full items-center space-x-2 justify-between">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"
v-model:task="editableTimeEntry.task_id"
:clients
:create-project
:create-client
:can-create-project="canCreateProjects()"
:currency="getOrganizationCurrencyString()"
size="xlarge"
class="bg-input-background"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="
enableEstimatedTime
"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-2">
<div class="flex-col">
<TagDropdown
v-model="editableTimeEntry.tags"
:create-tag
:tags="tags">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<TagIcon
v-if="editableTimeEntry.tags.length === 0"
class="w-4"></TagIcon>
<div
v-else
class="bg-accent-300/20 w-5 h-5 font-medium rounded flex items-center transition justify-center">
{{ editableTimeEntry.tags.length }}
</div>
<span>Tags</span>
</Badge>
</template>
</TagDropdown>
</div>
<div class="flex-col">
<SelectDropdown
v-model="billableProxy"
:get-key-from-item="(item: BillableOption) => item.value"
:get-name-for-item="(item: BillableOption) => item.label"
:items="[
{
label: 'Billable',
value: 'true',
},
{
label: 'Non Billable',
value: 'false',
},
]">
<template #trigger>
<Badge
class="bg-input-background"
tag="button"
size="xlarge">
<BillableIcon class="h-4"></BillableIcon>
<span>{{
editableTimeEntry.billable
? 'Billable'
: 'Non-Billable'
}}</span>
</Badge>
</template>
</SelectDropdown>
</div>
</div>
</div>
</div>
<div class="flex pt-4 space-x-4">
<div class="flex-1">
<InputLabel>Duration</InputLabel>
<div class="space-y-2 mt-1 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 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language here f.e.
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between w-full">
<SecondaryButton
tabindex="2"
class="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
:disabled="deleting || saving"
@click="deleteEntry">
{{ deleting ? 'Deleting...' : 'Delete' }}
</SecondaryButton>
<div class="flex space-x-3">
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
tabindex="2"
:class="{ 'opacity-25': saving }"
:disabled="saving || deleting"
@click="submit">
{{ saving ? 'Updating...' : 'Update Time Entry' }}
</PrimaryButton>
</div>
</div>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -27,7 +27,11 @@ import Checkbox from './Input/Checkbox.vue';
import TimeEntryGroupedTable from './TimeEntry/TimeEntryGroupedTable.vue';
import TimeEntryMassActionRow from './TimeEntry/TimeEntryMassActionRow.vue';
import TimeEntryCreateModal from './TimeEntry/TimeEntryCreateModal.vue';
import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue';
import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import CalendarEventContent from './Calendar/CalendarEventContent.vue';
import CalendarDayHeader from './Calendar/CalendarDayHeader.vue';
import TimeEntryCalendar from './Calendar/TimeEntryCalendar.vue';
export {
money,
@@ -52,4 +56,8 @@ export {
TimeEntryMassActionRow,
MoreOptionsDropdown,
TimeEntryCreateModal,
TimeEntryEditModal,
CalendarEventContent,
CalendarDayHeader,
TimeEntryCalendar,
};