Files
solidtime/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
2026-07-21 18:54:05 +02:00

341 lines
16 KiB
Vue

<script setup lang="ts">
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import TimeEntryRangeSelector from '@/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Member,
Project,
Tag,
Task,
TimeEntry,
} from '@/packages/api/src';
import TimeEntryDescriptionInput from '@/packages/ui/src/TimeEntry/TimeEntryDescriptionInput.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue';
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 {
Checkbox,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
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;
indent?: boolean;
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
members?: Member[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
onStartStopClick: () => void;
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;
showDate?: boolean;
selected?: boolean;
canCreateProject: boolean;
enableEstimatedTime: boolean;
isReport?: boolean;
}>();
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 });
}
function updateTimeEntryTags(tags: string[]) {
props.updateTimeEntry({ ...props.timeEntry, tags });
}
function updateTimeEntryBillable(billable: boolean) {
props.updateTimeEntry({ ...props.timeEntry, billable });
}
function updateStartEndTime(start: string, end: string | null) {
props.updateTimeEntry({ ...props.timeEntry, start, end });
}
function updateProjectAndTask(projectId: string, taskId: string) {
const project = props.projects.find((p) => p.id === projectId);
props.updateTimeEntry({
...props.timeEntry,
project_id: projectId,
task_id: taskId,
billable: project ? project.is_billable : props.timeEntry.billable,
});
}
const memberName = computed(() => {
if (props.members) {
const member = props.members.find((member) => member.user_id === props.timeEntry.user_id);
if (member) {
return member.name;
}
}
return '';
});
function onSelectChange(checked: boolean) {
if (checked) {
emit('selected');
} else {
emit('unselected');
}
}
function handleEdit() {
showEditModal.value = true;
}
async function handleUpdateTimeEntry(updatedEntry: TimeEntry) {
props.updateTimeEntry(updatedEntry);
showEditModal.value = false;
}
async function handleDeleteTimeEntry() {
props.deleteTimeEntry();
showEditModal.value = false;
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger as-child>
<div
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
<MainContainer class="min-w-0">
<div class="@xl:flex py-2 min-w-0 items-center justify-between group">
<!-- Desktop layout -->
<div class="hidden @lg:flex items-center min-w-0">
<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
:can-create-project
:clients
:projects="projects"
:tasks="tasks"
:project="timeEntry.project_id"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:enable-estimated-time
:task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div>
<div class="hidden @lg:flex items-center space-x-1 @lg:space-x-2 shrink-0">
<div v-if="showMember && members" class="text-sm px-2">
{{ 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
@changed="updateTimeEntryBillable"></BillableToggleButton>
<div class="flex-1">
<TimeEntryRangeSelector
:start="timeEntry.start"
:end="timeEntry.end"
:show-date
@changed="updateStartEndTime"></TimeEntryRangeSelector>
</div>
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
: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>
</div>
<!-- Mobile layout -->
<div class="@lg:hidden">
<!-- 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"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
</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
:can-create-project
:clients
:projects="projects"
:tasks="tasks"
:project="timeEntry.project_id"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:enable-estimated-time
:task="timeEntry.task_id"
@changed="
updateProjectAndTask
"></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>
</div>
</div>
</div>
</div>
</MainContainer>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="onStartStopClick()">
<PlayIcon class="w-4 h-4 text-icon-default" />
<span>Continue</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleEdit()">
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="duplicateTimeEntry?.()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem class="space-x-3 text-destructive" @select="deleteTimeEntry()">
<TrashIcon class="w-4 h-4 text-icon-default" />
<span>Delete</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<TimeEntryEditModal
v-if="showEditModal"
v-model:show="showEditModal"
:time-entry="timeEntry"
:enable-estimated-time="enableEstimatedTime"
:update-time-entry="handleUpdateTimeEntry"
:delete-time-entry="handleDeleteTimeEntry"
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
:tags="tags"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:can-create-project="canCreateProject" />
</template>
<style scoped></style>