mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
add break time entries and simplified time tracker ui
This commit is contained in:
225
resources/js/Components/Timesheet/BreakPlacementModal.vue
Normal file
225
resources/js/Components/Timesheet/BreakPlacementModal.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
|
||||
import { formatTime, getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import { computed, inject, ref, watch, type ComputedRef } from 'vue';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
import {
|
||||
BREAK_GAP_TOLERANCE_SECONDS,
|
||||
placementMode,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
type BreakPlacementRequest,
|
||||
} from '@/utils/timesheet/breakPlacementMath';
|
||||
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
const props = defineProps<{
|
||||
request: BreakPlacementRequest | null;
|
||||
apply: (breakStart: string, durationSeconds: number) => Promise<void>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ cancel: [] }>();
|
||||
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
const show = computed(() => props.request !== null);
|
||||
const mode = computed(() => (props.request ? placementMode(props.request) : null));
|
||||
const saving = ref(false);
|
||||
|
||||
const localStart = ref('');
|
||||
const localEnd = ref('');
|
||||
|
||||
// Seed the pickers from the suggested placement whenever a new request arrives.
|
||||
watch(
|
||||
() => props.request,
|
||||
(request) => {
|
||||
if (!request) return;
|
||||
localStart.value = getLocalizedDayJs(request.defaultBreakStart).format();
|
||||
localEnd.value = getLocalizedDayJs(request.defaultBreakStart)
|
||||
.add(request.durationSeconds, 'second')
|
||||
.format();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const utcStart = computed(() => getLocalizedDayJs(localStart.value).utc().format());
|
||||
const durationSeconds = computed(() =>
|
||||
getLocalizedDayJs(localEnd.value)
|
||||
.utc()
|
||||
.diff(getLocalizedDayJs(localStart.value).utc(), 'second')
|
||||
);
|
||||
|
||||
const splitPlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'split' || durationSeconds.value <= 0) return null;
|
||||
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value);
|
||||
});
|
||||
|
||||
const movePlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'move' || durationSeconds.value <= 0) return null;
|
||||
return planMoveInsert(
|
||||
[...props.request.workEntries, ...props.request.otherEntries],
|
||||
props.request.dayStart,
|
||||
props.request.dayEnd,
|
||||
utcStart.value,
|
||||
durationSeconds.value
|
||||
);
|
||||
});
|
||||
|
||||
// Non-blocking heads-up: the placement is feasible but the break would end up
|
||||
// further than the tolerance from work on either side, so it would carry the
|
||||
// misaligned warning right after being created. Mirrors getBreakPlacementHint,
|
||||
// but computed against the planned (post-shift) layout.
|
||||
const resultMisaligned = computed<boolean>(() => {
|
||||
const req = props.request;
|
||||
const plan = movePlan.value;
|
||||
if (!req || mode.value !== 'move' || !plan) return false;
|
||||
const dayjs = getDayJsInstance();
|
||||
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
|
||||
const breakStartMs = toMs(plan.breakSlot.start);
|
||||
const breakEndMs = toMs(plan.breakSlot.end);
|
||||
const shiftedById = new Map(plan.shifted.map((s) => [s.id, s]));
|
||||
|
||||
let prevWorkEndMs: number | null = null;
|
||||
let nextWorkStartMs: number | null = null;
|
||||
for (const entry of req.workEntries) {
|
||||
const planned = shiftedById.get(entry.id) ?? entry;
|
||||
const startMs = toMs(planned.start);
|
||||
const endMs = toMs(planned.end);
|
||||
if (endMs <= breakStartMs && (prevWorkEndMs === null || endMs > prevWorkEndMs)) {
|
||||
prevWorkEndMs = endMs;
|
||||
}
|
||||
if (startMs >= breakEndMs && (nextWorkStartMs === null || startMs < nextWorkStartMs)) {
|
||||
nextWorkStartMs = startMs;
|
||||
}
|
||||
}
|
||||
|
||||
const toleranceMs = BREAK_GAP_TOLERANCE_SECONDS * 1000;
|
||||
return (
|
||||
prevWorkEndMs === null ||
|
||||
breakStartMs - prevWorkEndMs > toleranceMs ||
|
||||
nextWorkStartMs === null ||
|
||||
nextWorkStartMs - breakEndMs > toleranceMs
|
||||
);
|
||||
});
|
||||
|
||||
const feasible = computed(() =>
|
||||
mode.value === 'split' ? splitPlan.value !== null : movePlan.value !== null
|
||||
);
|
||||
|
||||
function fmt(iso: string): string {
|
||||
return formatTime(iso, organization?.value?.time_format);
|
||||
}
|
||||
|
||||
const explanation = computed(() => {
|
||||
if (!props.request) return '';
|
||||
return mode.value === 'split'
|
||||
? "There's no free gap that fits this break, so the work entry will be split and the break placed inside it."
|
||||
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
|
||||
});
|
||||
|
||||
// Human-readable summary of what will change, so the user can confirm the edit.
|
||||
const changeSummary = computed<string[]>(() => {
|
||||
if (mode.value === 'split') {
|
||||
const plan = splitPlan.value;
|
||||
if (!plan) return [];
|
||||
return [
|
||||
`${fmt(plan.firstHalf.start)}–${fmt(plan.firstHalf.end)} (work)`,
|
||||
`${fmt(plan.breakSlot.start)}–${fmt(plan.breakSlot.end)} (break)`,
|
||||
`${fmt(plan.secondHalf.start)}–${fmt(plan.secondHalf.end)} (work)`,
|
||||
];
|
||||
}
|
||||
const plan = movePlan.value;
|
||||
if (!plan) return [];
|
||||
if (plan.shifted.length === 0) return ['No entries need to move.'];
|
||||
return plan.shifted.map((shift) => {
|
||||
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
|
||||
const original =
|
||||
props.request!.workEntries.find((e) => e.id === shift.id) ??
|
||||
props.request!.otherEntries.find((e) => e.id === shift.id)!;
|
||||
const label = `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)}`;
|
||||
return isBreak ? `${label} (break)` : label;
|
||||
});
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!feasible.value || durationSeconds.value <= 0) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await props.apply(utcStart.value, durationSeconds.value);
|
||||
} catch {
|
||||
// apply surfaces its own error toast; keep the modal open so the user can retry
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="emit('cancel')">
|
||||
<template #title>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Coffee class="w-5 h-5 text-text-secondary" />
|
||||
<span>Add break</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-text-secondary">{{ explanation }}</p>
|
||||
|
||||
<TimeRangeFields
|
||||
v-model:start="localStart"
|
||||
v-model:end="localEnd"
|
||||
date-picker-size="sm"></TimeRangeFields>
|
||||
|
||||
<div
|
||||
v-if="feasible"
|
||||
data-testid="break_placement_summary"
|
||||
class="rounded-lg border border-card-border bg-secondary/40 px-3 py-2 text-sm text-text-secondary space-y-1">
|
||||
<div class="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
|
||||
</div>
|
||||
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="feasible && resultMisaligned"
|
||||
data-testid="break_placement_misaligned_warning"
|
||||
class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
At this time the break would sit more than
|
||||
{{ BREAK_GAP_TOLERANCE_MINUTES }} minutes away from your work entries and will
|
||||
be flagged as misaligned.
|
||||
</div>
|
||||
<!-- `request` guard (not just !feasible): when the request is cleared on save,
|
||||
the dialog fades out with content still mounted — don't flash the error then -->
|
||||
<div
|
||||
v-if="!feasible && request"
|
||||
data-testid="break_placement_infeasible"
|
||||
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{
|
||||
mode === 'split'
|
||||
? "This break doesn't fit there — it must lie inside the work entry, leaving at least a minute of work on each side."
|
||||
: "This break doesn't fit at that time without pushing an entry outside the day. Try a shorter break or a different time."
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="emit('cancel')">Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving || !feasible }"
|
||||
:disabled="saving || !feasible"
|
||||
@click="submit">
|
||||
Add break
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user