+
{{ clientName }}
-
+
{{ formattedDuration }}
diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
index 422b6490..d6258198 100644
--- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
+++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue
@@ -4,7 +4,17 @@ 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, inject, type ComputedRef } from 'vue';
+import {
+ computed,
+ ref,
+ watch,
+ inject,
+ type ComputedRef,
+ nextTick,
+ onMounted,
+ onActivated,
+ onUnmounted,
+} from 'vue';
import chroma from 'chroma-js';
import { useCssVariable } from '@/utils/useCssVariable';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
@@ -12,6 +22,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import FullCalendarEventContent from './FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
+import activityStatusPlugin, {
+ type ActivityPeriod,
+ renderActivityStatusBoxes,
+} from './idleStatusPlugin';
import type {
TimeEntry,
Project,
@@ -24,7 +38,10 @@ import type {
} from '@/packages/api/src';
import type { Dayjs } from 'dayjs';
-type CalendarExtendedProps = { timeEntry: TimeEntry } & Record
;
+type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
+ string,
+ unknown
+>;
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
@@ -37,10 +54,13 @@ const props = defineProps<{
tasks: Task[];
clients: Client[];
tags: Tag[];
+ activityPeriods?: ActivityPeriod[];
loading?: boolean;
// Permissions / feature flags
enableEstimatedTime: boolean;
+ currency: string;
+ canCreateProject: boolean;
createTimeEntry: (
entry: Omit
@@ -61,6 +81,10 @@ const selectedTimeEntry = ref(null);
const calendarRef = ref | null>(null);
+// Reactive "now" for running time entry - updates every minute
+const currentTime = ref(getDayJsInstance()());
+let currentTimeInterval: ReturnType | null = null;
+
// Inject organization data for settings
const organization = inject>('organization');
@@ -102,47 +126,52 @@ const events = computed(() => {
const themeBackground = (() => {
return cssBackground.value?.trim();
})();
- 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'
- );
+ return props.timeEntries?.map((timeEntry) => {
+ const isRunning = timeEntry.end === null;
+ 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);
+
+ // For running entries, use current time as end
+ const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
+ const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
+
+ const title = timeEntry.description || 'No description';
+
+ const baseColor = project?.color || '#6B7280';
+ const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
+ const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
+
+ // For 0-duration events, display them with minimum visual duration but preserve actual duration
+ const startTime = getLocalizedDayJs(timeEntry.start);
+ const endTime =
+ duration === 0
+ ? startTime.add(1, 'second') // Show as 1 second for minimal visibility
+ : isRunning
+ ? getLocalizedDayJs(currentTime.value.toISOString())
+ : getLocalizedDayJs(timeEntry.end!);
- const title = timeEntry.description || 'No description';
-
- const baseColor = project?.color || '#6B7280';
- const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
- const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
-
- // For 0-duration events, display them with minimum visual duration but preserve actual duration
- const startTime = getLocalizedDayJs(timeEntry.start);
- const endTime =
- duration === 0
- ? startTime.add(1, 'second') // Show as 1 second for minimal visibility
- : getLocalizedDayJs(timeEntry.end!);
-
- return {
- id: timeEntry.id,
- start: startTime.format(),
- end: endTime.format(),
- title,
- backgroundColor,
- borderColor,
- textColor: 'var(--foreground)',
- extendedProps: {
- timeEntry,
- project,
- client,
- task,
- duration,
- },
- };
- });
+ return {
+ id: timeEntry.id,
+ start: startTime.format(),
+ end: endTime.format(),
+ title,
+ backgroundColor,
+ borderColor,
+ textColor: 'var(--foreground)',
+ // For running entries: disable dragging and resizing
+ startEditable: !isRunning,
+ classNames: isRunning ? ['running-entry'] : [],
+ extendedProps: {
+ timeEntry,
+ project,
+ client,
+ task,
+ duration,
+ isRunning,
+ },
+ };
+ });
});
// Daily totals used in day header
@@ -163,6 +192,8 @@ const dailyTotals = computed(() => {
function emitDatesChange(arg: DatesSetArg) {
emit('dates-change', { start: arg.start, end: arg.end });
+ // Render activity boxes after calendar view has been rendered
+ renderActivityBoxes();
}
function handleDateSelect(arg: { start: Date; end: Date }) {
@@ -181,6 +212,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
function handleEventClick(arg: EventClickArg) {
const ext = arg.event.extendedProps as CalendarExtendedProps;
+ // Don't open edit modal for running time entries
+ if (ext.isRunning) {
+ return;
+ }
selectedTimeEntry.value = ext.timeEntry;
showEditTimeEntryModal.value = true;
}
@@ -194,11 +229,13 @@ async function handleEventDrop(arg: EventDropArg) {
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
+ .second(0)
.utc()
.format(),
end: getDayJsInstance()(arg.event.end.toISOString())
.utc()
.tz(getUserTimezone(), true)
+ .second(0)
.utc()
.format(),
} as TimeEntry;
@@ -215,20 +252,25 @@ async function handleEventResize(arg: EventChangeArg) {
start: getDayJsInstance()(arg.event.start.toISOString())
.utc()
.tz(getUserTimezone(), true)
+ .second(0)
.utc()
.format(),
- end: getDayJsInstance()(arg.event.end.toISOString())
- .utc()
- .tz(getUserTimezone(), true)
- .utc()
- .format(),
+ // Preserve null end for running entries
+ end: ext.isRunning
+ ? null
+ : getDayJsInstance()(arg.event.end.toISOString())
+ .utc()
+ .tz(getUserTimezone(), true)
+ .second(0)
+ .utc()
+ .format(),
} as TimeEntry;
await props.updateTimeEntry(updatedTimeEntry);
emit('refresh');
}
const calendarOptions = computed(() => ({
- plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
+ plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
initialView: 'timeGridWeek',
headerToolbar: {
left: 'prev,next today',
@@ -241,10 +283,11 @@ const calendarOptions = computed(() => ({
slotDuration: '00:15:00',
slotLabelInterval: '01:00:00',
slotLabelFormat: getSlotLabelFormat(),
- snapDuration: '00:15:00',
+ snapDuration: '00:01:00',
firstDay: getFirstDay(),
allDaySlot: false,
nowIndicator: true,
+ eventMinHeight: 1,
selectable: true,
selectMirror: true,
editable: true,
@@ -259,6 +302,7 @@ const calendarOptions = computed(() => ({
datesSet: emitDatesChange,
events: events.value,
+ activityPeriods: props.activityPeriods || [],
}));
watch(showCreateTimeEntryModal, (value) => {
@@ -277,6 +321,60 @@ watch(showEditTimeEntryModal, (value) => {
emit('refresh');
}
});
+
+// Render activity status boxes after FullCalendar has rendered
+const renderActivityBoxes = () => {
+ if (!calendarRef.value || !props.activityPeriods) return;
+
+ const calendarEl = calendarRef.value.$el as HTMLElement;
+ if (calendarEl && props.activityPeriods.length > 0) {
+ renderActivityStatusBoxes(calendarEl, props.activityPeriods);
+ }
+};
+
+// Watch for activity periods changes - re-render when data changes
+watch(
+ () => props.activityPeriods,
+ () => {
+ renderActivityBoxes();
+ }
+);
+
+const scrollToCurrentTime = () => {
+ nextTick(() => {
+ if (calendarRef.value) {
+ const now = getDayJsInstance()();
+ const oneHourBefore = now.subtract(1, 'hour');
+
+ // If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
+ const scrollTime = now.isSame(oneHourBefore, 'day')
+ ? oneHourBefore.format('HH:mm:ss')
+ : now.format('HH:mm:ss');
+
+ calendarRef.value.getApi().scrollToTime(scrollTime);
+ }
+ });
+};
+
+onMounted(() => {
+ scrollToCurrentTime();
+ // Start interval to update running time entry
+ currentTimeInterval = setInterval(() => {
+ currentTime.value = getDayJsInstance()();
+ }, 60000); // Update every minute
+});
+
+onActivated(() => {
+ scrollToCurrentTime();
+});
+
+onUnmounted(() => {
+ // Clean up interval
+ if (currentTimeInterval) {
+ clearInterval(currentTimeInterval);
+ currentTimeInterval = null;
+ }
+});
@@ -295,6 +393,8 @@ watch(showEditTimeEntryModal, (value) => {
:create-client="createClient"
:create-project="createProject"
:create-tag="createTag"
+ :currency="currency"
+ :can-create-project="canCreateProject"
:tags="tags as any"
:projects="projects"
:tasks="tasks"
@@ -314,7 +414,9 @@ watch(showEditTimeEntryModal, (value) => {
:tags="tags as any"
:projects="projects"
:tasks="tasks"
- :clients="clients" />
+ :clients="clients"
+ :currency="currency"
+ :can-create-project="canCreateProject" />
{
}
.fullcalendar :deep(.fc-timegrid-slot-label) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
}
.fullcalendar :deep(.fc-toolbar) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
padding: 0.5rem;
margin-bottom: 0;
}
@@ -452,8 +554,8 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-event) {
- border-radius: var(--radius);
- padding: 0.45rem 0.25rem;
+ border-radius: calc(var(--radius) - 4px);
+ padding: 0;
font-size: 0.75rem;
cursor: pointer;
box-shadow: var(--theme-shadow-card);
@@ -515,7 +617,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-highlight) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--primary);
}
.fullcalendar :deep(.fc-select-mirror) {
@@ -533,7 +635,7 @@ watch(showEditTimeEntryModal, (value) => {
}
.fullcalendar :deep(.fc-timegrid-body) {
- background-color: var(--theme-color-default-background);
+ background-color: var(--background);
}
.fullcalendar :deep(.fc-timegrid-col) {
@@ -600,4 +702,44 @@ watch(showEditTimeEntryModal, (value) => {
.fullcalendar :deep(.fc-event-main) {
padding: 0.125rem 0.25rem;
}
+
+/* Activity status plugin styles */
+.fullcalendar :deep(.activity-status-box) {
+ transition: opacity 0.2s ease;
+}
+
+.fullcalendar :deep(.activity-status-box.idle) {
+ background-color: rgba(156, 163, 175, 0.1) !important;
+}
+
+.fullcalendar :deep(.activity-status-box.idle):hover {
+ background-color: rgba(156, 163, 175, 0.5) !important;
+}
+
+.fullcalendar :deep(.activity-status-box.active) {
+ background-color: rgba(34, 197, 94, 0.3) !important;
+}
+
+.fullcalendar :deep(.activity-status-box.active):hover {
+ background-color: rgba(34, 197, 94, 1) !important;
+}
+
+/* Add left margin to events only on days with activity status data */
+.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
+ margin-left: 15px !important;
+}
+
+.fullcalendar :deep(.fc-timegrid-event) {
+ margin-left: 0 !important;
+}
+
+/* Hide end resizer for running time entries */
+.fullcalendar :deep(.running-entry .fc-event-resizer-end) {
+ display: none;
+}
+
+.fullcalendar :deep(.running-entry) {
+ border-bottom-left-radius: 0px;
+ border-bottom-right-radius: 0px;
+}
diff --git a/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
new file mode 100644
index 00000000..c525e6ca
--- /dev/null
+++ b/resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
@@ -0,0 +1,241 @@
+import { createPlugin, type PluginDef } from '@fullcalendar/core';
+import { computePosition, flip, shift, offset } from '@floating-ui/dom';
+
+export interface ActivityPeriod {
+ start: string;
+ end: string;
+ isIdle: boolean;
+}
+
+export interface ActivityStatusPluginOptions {
+ activityPeriods?: ActivityPeriod[];
+}
+
+/**
+ * Creates and manages a tooltip element for activity status boxes
+ */
+function createTooltip(): HTMLElement {
+ const tooltip = document.createElement('div');
+ tooltip.className =
+ 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
+ tooltip.style.position = 'fixed';
+ tooltip.style.pointerEvents = 'none';
+ tooltip.style.opacity = '0';
+ tooltip.style.whiteSpace = 'nowrap';
+ tooltip.style.transform = 'scale(0.95)';
+ tooltip.style.transition = 'opacity 150ms, transform 150ms';
+ document.body.appendChild(tooltip);
+ return tooltip;
+}
+
+/**
+ * Shows tooltip for an activity status box
+ */
+function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
+ tooltip.textContent = text;
+ tooltip.style.opacity = '1';
+ tooltip.style.transform = 'scale(1)';
+
+ const updatePosition = () => {
+ computePosition(box, tooltip, {
+ placement: 'right',
+ middleware: [offset(8), flip(), shift({ padding: 5 })],
+ }).then(({ x, y }) => {
+ tooltip.style.left = `${x}px`;
+ tooltip.style.top = `${y}px`;
+ });
+ };
+
+ updatePosition();
+}
+
+/**
+ * Hides the tooltip
+ */
+function hideTooltip(tooltip: HTMLElement) {
+ tooltip.style.opacity = '0';
+ tooltip.style.transform = 'scale(0.95)';
+}
+
+/**
+ * Renders activity status boxes in the calendar time grid
+ */
+export function renderActivityStatusBoxes(
+ calendarEl: HTMLElement,
+ activityPeriods: ActivityPeriod[]
+) {
+ if (!calendarEl) return;
+
+ // Clean up existing activity boxes and markers first
+ const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
+ existingBoxes.forEach((box) => box.remove());
+
+ // Clean up existing tooltips
+ const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
+ existingTooltips.forEach((tooltip) => tooltip.remove());
+
+ // Remove has-activity-status class from all lanes
+ const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
+ allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
+
+ const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
+ if (!timeGrid) {
+ console.log('No timegrid found');
+ return;
+ }
+
+ const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
+ if (lanes.length === 0) {
+ console.log('No lanes found');
+ return;
+ }
+
+ console.log(
+ 'Rendering activity status boxes, lanes:',
+ lanes.length,
+ 'periods:',
+ activityPeriods.length
+ );
+
+ // Create a single tooltip instance to be reused
+ const tooltip = createTooltip();
+
+ lanes.forEach((lane: Element, dayIndex: number) => {
+ // Get the date for this lane from the data attribute
+ const laneEl = lane as HTMLElement;
+ const dateStr = laneEl.getAttribute('data-date');
+
+ if (!dateStr) {
+ console.log('No date attribute found for lane', dayIndex);
+ return;
+ }
+
+ const laneDate = new Date(dateStr);
+ const laneDateStart = new Date(laneDate);
+ laneDateStart.setHours(0, 0, 0, 0);
+ const laneDateEnd = new Date(laneDate);
+ laneDateEnd.setHours(23, 59, 59, 999);
+
+ let hasActivityStatusForThisDay = false;
+
+ activityPeriods.forEach((period) => {
+ const periodStart = new Date(period.start);
+ const periodEnd = new Date(period.end);
+
+ // Check if period overlaps with this day
+ if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
+ return;
+ }
+
+ // Calculate the position and height of the idle box
+ const { top, height } = calculateBoxPosition(
+ calendarEl,
+ periodStart > laneDateStart ? periodStart : laneDateStart,
+ periodEnd < laneDateEnd ? periodEnd : laneDateEnd
+ );
+
+ if (height <= 0) return;
+
+ hasActivityStatusForThisDay = true;
+
+ // Create and append the activity status box
+ const box = document.createElement('div');
+ box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
+ box.style.position = 'absolute';
+ box.style.top = `${top}px`;
+ box.style.height = `${height}px`;
+ box.style.width = '8px';
+ box.style.left = '4px';
+ box.style.right = '4px';
+ box.style.zIndex = '10';
+ box.style.cursor = 'default';
+
+ // Calculate duration in minutes
+ const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
+ const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
+ const durationMs = actualEnd.getTime() - actualStart.getTime();
+ const durationMinutes = Math.round(durationMs / 60000);
+
+ // Format duration
+ const hours = Math.floor(durationMinutes / 60);
+ const minutes = durationMinutes % 60;
+ const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+
+ // Add tooltip text based on status
+ const status = period.isIdle ? 'Idling' : 'Active';
+ const tooltipText = `${status} (${durationText})`;
+
+ // Add hover event listeners for tooltip
+ box.addEventListener('mouseenter', () => {
+ showTooltip(box, tooltip, tooltipText);
+ });
+
+ box.addEventListener('mouseleave', () => {
+ hideTooltip(tooltip);
+ });
+
+ // Position relative to the lane
+ const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
+ if (laneFrame) {
+ laneFrame.appendChild(box);
+ } else {
+ console.log('No lane frame found');
+ }
+ });
+
+ // Mark this lane as having activity status if any periods were rendered
+ if (hasActivityStatusForThisDay) {
+ laneEl.classList.add('has-activity-status');
+ }
+ });
+}
+
+/**
+ * Calculates the pixel position and height for an activity status box
+ */
+function calculateBoxPosition(
+ calendarEl: HTMLElement,
+ startTime: Date,
+ endTime: Date
+): { top: number; height: number } {
+ // Get the slot duration and slot height
+ const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
+ if (slotsEl.length === 0) {
+ console.log('No slots found');
+ return { top: 0, height: 0 };
+ }
+
+ // Calculate slot height (assuming all slots are equal height)
+ const firstSlot = slotsEl[0] as HTMLElement;
+ const slotHeight = firstSlot.offsetHeight;
+
+ // Each slot is 15 minutes by default (configured in TimeEntryCalendar)
+ const slotDurationMinutes = 15;
+ const pixelsPerMinute = slotHeight / slotDurationMinutes;
+
+ // Calculate start position (minutes from midnight)
+ const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
+ const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
+
+ // Calculate pixel positions
+ const top = startMinutes * pixelsPerMinute;
+ const height = (endMinutes - startMinutes) * pixelsPerMinute;
+
+ return { top, height };
+}
+
+/**
+ * FullCalendar plugin to display idle/active status boxes in the time grid
+ */
+const activityStatusPlugin: PluginDef = createPlugin({
+ name: '@solidtime/activity-status',
+
+ optionRefiners: {
+ activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
+ if (!Array.isArray(rawVal)) return [];
+ return rawVal as ActivityPeriod[];
+ },
+ },
+});
+
+export default activityStatusPlugin;
diff --git a/resources/js/packages/ui/src/Input/DateRangePicker.vue b/resources/js/packages/ui/src/Input/DateRangePicker.vue
index e01cf973..65befd63 100644
--- a/resources/js/packages/ui/src/Input/DateRangePicker.vue
+++ b/resources/js/packages/ui/src/Input/DateRangePicker.vue
@@ -1,6 +1,6 @@
-
-
-
- {
- selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
- }
+
+
+ {
+ @select-all="selectAllTimeEntries(value)"
+ @unselect-all="unselectAllTimeEntries(value)">
+
+ {
+ selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
+ }
+ "
+ @unselected="
+ (timeEntriesToUnselect: TimeEntry[]) => {
+ selectedTimeEntries = selectedTimeEntries.filter(
+ (item: TimeEntry) =>
+ !timeEntriesToUnselect.find(
+ (filterEntry: TimeEntry) => filterEntry.id === item.id
+ )
+ );
+ }
+ ">
+
- !timeEntriesToUnselect.find(
- (filterEntry: TimeEntry) => filterEntry.id === item.id
- )
- );
- }
- ">
- item.id !== entry.id
- )
- ">
-
+ (item: TimeEntry) => item.id !== entry.id
+ )
+ ">
+
+
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue
index 40d7062e..b347ddaf 100644
--- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue
+++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRangeSelector.vue
@@ -35,11 +35,11 @@ const organization = inject
>('organization');
data-testid="time_entry_range_selector"
:class="
twMerge(
- 'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
+ 'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
- organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
+ organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
open && 'border-card-border bg-card-background'
)
">
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
index c92a1a13..ac60df01 100644
--- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
+++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRow.vue
@@ -112,7 +112,7 @@ async function handleDeleteTimeEntry() {
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
data-testid="time_entry_row">
-
+
@@ -134,7 +134,7 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id"
@changed="updateProjectAndTask">
-
+
{{ memberName }}
@@ -186,7 +186,9 @@ async function handleDeleteTimeEntry() {
:tags="tags"
:projects="projects"
:tasks="tasks"
- :clients="clients" />
+ :clients="clients"
+ :currency="currency"
+ :can-create-project="canCreateProject" />
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue
index 99d8a246..68917d31 100644
--- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue
+++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowDurationInput.vue
@@ -77,7 +77,7 @@ function selectInput(event: Event) {
v-model="currentTime"
data-testid="time_entry_duration_input"
name="Duration"
- class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
+ class="text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
@focus="selectInput"
@keydown.tab="open = false"
@blur="updateTimerAndStartLiveTimerUpdate"
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue
index 46ff295a..90c1d818 100644
--- a/resources/js/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue
+++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue
@@ -32,13 +32,13 @@ function selectUnselectAll(value: boolean) {
+ class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
-
+
{{
formatHumanReadableDuration(
diff --git a/resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue b/resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue
index b2df6da5..0c469c2b 100644
--- a/resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue
+++ b/resources/js/packages/ui/src/TimeTracker/TimeTrackerControls.vue
@@ -15,8 +15,6 @@ import type {
} from '@/packages/api/src';
import { computed, nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
-import { useTimeEntriesStore } from '@/utils/useTimeEntries';
-import { storeToRefs } from 'pinia';
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';
@@ -34,6 +32,7 @@ const props = defineProps<{
tasks: Task[];
tags: Tag[];
clients: Client[];
+ timeEntries: TimeEntry[];
createTag: (name: string) => Promise;
createProject: (project: CreateProjectBody) => Promise;
createClient: (client: CreateClientBody) => Promise;
@@ -131,10 +130,9 @@ function updateTimeEntryDescription() {
}
}
-const { timeEntries } = storeToRefs(useTimeEntriesStore());
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
- const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
+ 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) => {
diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts
index 37ee6b07..246ec592 100644
--- a/resources/js/packages/ui/src/index.ts
+++ b/resources/js/packages/ui/src/index.ts
@@ -10,8 +10,12 @@ import * as color from './utils/color';
import * as random from './utils/random';
import * as time from './utils/time';
+export { cn } from './utils/cn';
+export { buttonVariants, type ButtonVariants } from './Buttons/index';
+
import PrimaryButton from './Buttons/PrimaryButton.vue';
import SecondaryButton from './Buttons/SecondaryButton.vue';
+import Button from './Buttons/Button.vue';
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
import ProjectBadge from './Project/ProjectBadge.vue';
import LoadingSpinner from './LoadingSpinner.vue';
@@ -20,6 +24,7 @@ import TextInput from './Input/TextInput.vue';
import InputLabel from './Input/InputLabel.vue';
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
+import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
import CardTitle from './CardTitle.vue';
import SelectDropdown from './Input/SelectDropdown.vue';
import Badge from './Badge.vue';
@@ -32,12 +37,15 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
+export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
export {
money,
color,
random,
time,
+ Button,
PrimaryButton,
SecondaryButton,
TimeTrackerStartStop,
@@ -48,6 +56,7 @@ export {
InputLabel,
TimeTrackerRunningInDifferentOrganizationOverlay,
TimeTrackerControls,
+ TimeTrackerMoreOptionsDropdown,
CardTitle,
SelectDropdown,
Badge,
@@ -60,4 +69,8 @@ export {
FullCalendarEventContent,
FullCalendarDayHeader,
TimeEntryCalendar,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
};
diff --git a/resources/js/packages/ui/src/tooltip/Tooltip.vue b/resources/js/packages/ui/src/tooltip/Tooltip.vue
new file mode 100644
index 00000000..0190080e
--- /dev/null
+++ b/resources/js/packages/ui/src/tooltip/Tooltip.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/tooltip/TooltipContent.vue b/resources/js/packages/ui/src/tooltip/TooltipContent.vue
new file mode 100644
index 00000000..1bf344ff
--- /dev/null
+++ b/resources/js/packages/ui/src/tooltip/TooltipContent.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/tooltip/TooltipProvider.vue b/resources/js/packages/ui/src/tooltip/TooltipProvider.vue
new file mode 100644
index 00000000..376e586e
--- /dev/null
+++ b/resources/js/packages/ui/src/tooltip/TooltipProvider.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/tooltip/TooltipTrigger.vue b/resources/js/packages/ui/src/tooltip/TooltipTrigger.vue
new file mode 100644
index 00000000..127d097d
--- /dev/null
+++ b/resources/js/packages/ui/src/tooltip/TooltipTrigger.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/resources/js/packages/ui/src/tooltip/index.ts b/resources/js/packages/ui/src/tooltip/index.ts
new file mode 100644
index 00000000..94e681c8
--- /dev/null
+++ b/resources/js/packages/ui/src/tooltip/index.ts
@@ -0,0 +1,4 @@
+export { default as Tooltip } from './Tooltip.vue';
+export { default as TooltipContent } from './TooltipContent.vue';
+export { default as TooltipProvider } from './TooltipProvider.vue';
+export { default as TooltipTrigger } from './TooltipTrigger.vue';
diff --git a/resources/js/packages/ui/src/utils/cn.ts b/resources/js/packages/ui/src/utils/cn.ts
new file mode 100644
index 00000000..864391a6
--- /dev/null
+++ b/resources/js/packages/ui/src/utils/cn.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs.filter(Boolean)));
+}
diff --git a/resources/js/packages/ui/styles.css b/resources/js/packages/ui/styles.css
new file mode 100644
index 00000000..6d6a4b88
--- /dev/null
+++ b/resources/js/packages/ui/styles.css
@@ -0,0 +1,240 @@
+/**
+ * Shared styles for solidtime
+ * This CSS file contains all the shared theme variables and base styles
+ * used by both the main solidtime app and the desktop app.
+ *
+ * Font-face declarations are intentionally omitted here as they differ between apps:
+ * - Main app uses 'Inter'
+ * - Desktop app uses 'Outfit'
+ * Each app should include their own font-face declarations.
+ */
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root.dark {
+ --color-bg-primary: #101012;
+ --color-bg-secondary: #17181b;
+ --color-bg-tertiary: #2a2c32;
+ --color-bg-quaternary: #141518;
+ --color-bg-background: #090909;
+ --color-text-primary: #ffffff;
+ --color-text-secondary: #e3e4e6;
+ --color-text-tertiary: #969799;
+ --color-text-quaternary: #595a5c;
+
+ --color-border-primary: #191b1f;
+ --color-border-secondary: #23252a;
+ --color-border-tertiary: #2c2e33;
+ --color-border-quaternary: #393b42;
+ --color-input-border-active: rgba(255, 255, 255, 0.3);
+
+ --theme-color-chart: var(--color-accent-200);
+
+ --theme-color-menu-active: var(--color-bg-secondary);
+ --theme-color-card-background: var(--color-bg-secondary);
+ --theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
+ --theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
+
+ --theme-color-card-background-active: var(--color-bg-tertiary);
+
+ --theme-color-row-background: var(--color-bg-primary);
+ --theme-color-row-heading-background: var(--theme-color-card-background);
+ --theme-color-row-heading-border: var(--theme-color-card-border);
+ --theme-color-icon-default: var(--color-text-tertiary);
+
+ --theme-color-ring: rgba(255, 255, 255, 0.5);
+
+ --theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
+ --theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
+ --theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
+ --theme-color-button-primary-text: var(--color-text-primary);
+
+ --theme-color-input-background: var(--color-bg-secondary);
+
+ --theme-color-input-select-active: rgb(var(--color-accent-300));
+ --theme-color-input-select-active-hover: rgb(var(--color-accent-200));
+
+ --color-accent-default: rgba(var(--color-accent-300), 0.2);
+ --color-accent-foreground: rgb(var(--color-accent-100));
+ --theme-color-default-background: var(--color-bg-primary);
+}
+
+:root.light {
+ --color-bg-primary: #ffffff;
+ --color-bg-secondary: #f7f7f8;
+ --color-bg-tertiary: #eeeeef;
+ --color-bg-quaternary: #e1e1e3;
+ --color-bg-background: #f5f5f5;
+ --color-text-primary: #18181b;
+ --color-text-secondary: #3f3f46;
+ --color-text-tertiary: #57575c;
+ --color-text-quaternary: #a1a1aa;
+ --color-border-primary: #e7e7e7;
+ --color-border-secondary: #e5e5e5;
+ --color-border-tertiary: #dfdfdf;
+ --color-border-quaternary: #d1d1d1;
+ --color-input-border-active: rgba(0, 0, 0, 0.3);
+ --theme-color-menu-active: var(--color-bg-quaternary);
+
+ --theme-color-card-background: var(--color-bg-primary);
+ --theme-color-card-background-active: var(--color-bg-tertiary);
+
+ --theme-color-chart: var(--color-accent-400);
+
+ --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-card-background);
+ --theme-color-row-heading-background: var(--color-bg-secondary);
+ --theme-color-row-heading-border: var(--color-border-tertiary);
+ --theme-color-icon-default: var(--color-text-quaternary);
+
+ --theme-color-ring: rgba(0, 0, 0, 0.7);
+
+ --theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
+ --theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
+ --theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
+ --theme-color-button-primary-text: #ffffff;
+
+ --theme-color-input-background: var(--color-bg-primary);
+
+ --theme-color-input-select-active: rgb(var(--color-accent-400));
+ --theme-color-input-select-active-hover: rgb(var(--color-accent-500));
+
+ --color-accent-default: rgb(var(--color-accent-100));
+ --color-accent-foreground: rgb(var(--color-accent-800));
+ --theme-color-default-background: #fcfcfc;
+}
+
+:root {
+ --theme-color-icon-active: rgb(var(--color-text-tertiary));
+ --theme-color-card-background-separator: var(--color-border-tertiary);
+ --theme-color-card-border: var(--color-border-secondary);
+ --theme-color-card-border-active: var(--color-border-tertiary);
+ --theme-color-default-background-separator: var(--color-border-primary);
+ --theme-color-primary-text: var(--color-text-primary);
+ --theme-color-input-border: var(--color-border-quaternary);
+ --theme-color-tab-background: var(--theme-color-card-background);
+ --theme-color-tab-background-active: var(--theme-color-card-background-active);
+ --theme-color-tab-border: var(--theme-color-card-border);
+ --theme-color-row-separator-background: var(--theme-color-default-background-separator);
+ --theme-color-row-border: var(--theme-color-card-border);
+
+ --color-accent-50: 240, 249, 255; /* sky-50 */
+ --color-accent-100: 224, 242, 254; /* sky-100 */
+ --color-accent-200: 186, 230, 253; /* sky-200 */
+ --color-accent-300: 125, 211, 252; /* sky-300 */
+ --color-accent-400: 56, 189, 248; /* sky-400 */
+ --color-accent-500: 14, 165, 233; /* sky-500 */
+ --color-accent-600: 2, 132, 199; /* sky-600 */
+ --color-accent-700: 3, 105, 161; /* sky-700 */
+ --color-accent-800: 7, 89, 133; /* sky-800 */
+ --color-accent-900: 12, 74, 110; /* sky-900 */
+ --color-accent-950: 8, 47, 73; /* sky-950 */
+
+ --theme-button-secondary-background: var(--theme-color-card-background);
+ --theme-button-secondary-background-active: var(--theme-color-card-background-active);
+ --popover-border: var(--color-border-secondary);
+}
+
+* {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* width */
+::-webkit-scrollbar {
+ width: 5px;
+}
+
+/* Track */
+::-webkit-scrollbar-track,
+::-webkit-scrollbar-corner {
+ background: transparent;
+}
+
+/* Handle */
+::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 2px;
+}
+
+/* Handle on hover */
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
+
+[x-cloak] {
+ display: none;
+}
+
+body {
+ background-color: var(--theme-color-default-background);
+}
+
+@layer base {
+ :root {
+ --background: var(--color-bg-background);
+ --foreground: var(--color-text-primary);
+ --card: var(--theme-color-card-background);
+ --card-foreground: var(--color-text-primary);
+ --popover: var(--theme-color-card-background);
+ --popover-foreground: var(--color-text-primary);
+ --primary: var(--color-bg-primary);
+ --primary-foreground: var(--theme-color-button-primary-text);
+ --secondary: var(--color-bg-secondary);
+ --secondary-foreground: var(--color-text-primary);
+ --muted: var(--color-bg-tertiary);
+ --muted-foreground: var(--color-text-tertiary);
+ --accent: var(--theme-color-button-primary-background);
+ --accent-foreground: var(--theme-color-button-primary-text);
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: var(--color-text-primary);
+ --border: var(--color-border-primary);
+ --input: var(--color-border-tertiary);
+ --ring: var(--theme-color-ring);
+ --chart-1: var(--color-accent-400);
+ --chart-2: var(--color-accent-500);
+ --chart-3: var(--color-accent-600);
+ --chart-4: var(--color-accent-700);
+ --chart-5: var(--color-accent-800);
+ --radius: 0.5rem;
+ }
+ .dark {
+ --background: var(--color-bg-background);
+ --foreground: var(--color-text-primary);
+ --card: var(--theme-color-card-background);
+ --card-foreground: var(--color-text-primary);
+ --popover: var(--theme-color-card-background);
+ --popover-foreground: var(--color-text-primary);
+ --primary: var(--color-bg-primary);
+ --primary-foreground: var(--theme-color-button-primary-text);
+ --secondary: var(--color-bg-secondary);
+ --secondary-foreground: var(--color-text-primary);
+ --muted: var(--color-bg-tertiary);
+ --muted-foreground: var(--color-text-tertiary);
+ --accent: var(--theme-color-button-primary-background);
+ --accent-foreground: var(--theme-color-button-primary-text);
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: var(--color-text-primary);
+ --border: var(--color-border-primary);
+ --input: var(--color-border-tertiary);
+ --ring: var(--theme-color-ring);
+ --chart-1: var(--color-accent-200);
+ --chart-2: var(--color-accent-300);
+ --chart-3: var(--color-accent-400);
+ --chart-4: var(--color-accent-500);
+ --chart-5: var(--color-accent-600);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/resources/js/packages/ui/tailwind.theme.js b/resources/js/packages/ui/tailwind.theme.js
new file mode 100644
index 00000000..8db2f83c
--- /dev/null
+++ b/resources/js/packages/ui/tailwind.theme.js
@@ -0,0 +1,131 @@
+/**
+ * Shared Tailwind theme configuration for solidtime
+ * This configuration is used by both the main solidtime app and the desktop app
+ *
+ * Note: fontFamily is intentionally omitted here as it differs between apps:
+ * - Main app uses 'Inter'
+ * - Desktop app uses 'Outfit'
+ * Each app should override the fontFamily in their own config.
+ */
+export const solidtimeTheme = {
+ boxShadow: {
+ card: 'var(--theme-shadow-card)',
+ dropdown: 'var(--theme-shadow-dropdown)',
+ },
+ containers: {
+ '2xs': '16rem',
+ },
+ fontSize: {
+ '2xs': ['0.625rem', { lineHeight: '0.75rem' }],
+ xs: ['0.75rem', { lineHeight: '1rem' }],
+ sm: ['0.8125rem', { lineHeight: '1.125rem' }],
+ base: ['0.875rem', { lineHeight: '1.25rem' }],
+ lg: ['1rem', { lineHeight: '1.5rem' }],
+ xl: ['1.125rem', { lineHeight: '1.75rem' }],
+ '2xl': ['1.25rem', { lineHeight: '1.75rem' }],
+ '3xl': ['1.5rem', { lineHeight: '2rem' }],
+ '4xl': ['1.75rem', { lineHeight: '2.25rem' }],
+ '5xl': ['2rem', { lineHeight: '1' }],
+ '6xl': ['2.25rem', { lineHeight: '1' }],
+ '7xl': ['2.5rem', { lineHeight: '1' }],
+ '8xl': ['3rem', { lineHeight: '1' }],
+ '9xl': ['3.5rem', { lineHeight: '1' }],
+ },
+ colors: {
+ ring: 'var(--ring)',
+ primary: {
+ DEFAULT: 'var(--primary)',
+ foreground: 'var(--primary-foreground)',
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))',
+ },
+ tertiary: 'var(--color-bg-tertiary)',
+ quaternary: 'var(--color-bg-quaternary)',
+ background: 'var(--background)',
+ 'text-primary': 'var(--color-text-primary)',
+ 'text-secondary': 'var(--color-text-secondary)',
+ 'text-tertiary': 'var(--color-text-tertiary)',
+ 'text-quaternary': 'var(--color-text-quaternary)',
+ 'border-primary': 'var(--color-border-primary)',
+ 'border-secondary': 'var(--color-border-secondary)',
+ 'border-tertiary': 'var(--color-border-tertiary)',
+ 'default-background': 'var(--theme-color-default-background)',
+ 'default-background-separator': 'var(--theme-color-default-background-separator)',
+ 'row-background': 'var(--theme-color-row-background)',
+ 'card-background': 'var(--theme-color-card-background)',
+ 'card-background-active': 'var(--theme-color-card-background-active)',
+ 'card-background-separator': 'var(--theme-color-card-background-separator)',
+ 'card-border': 'var(--theme-color-card-border)',
+ 'card-border-active': 'var(--theme-color-card-border-active)',
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))',
+ },
+ 'tab-background': 'var(--theme-color-tab-background)',
+ 'tab-background-active': 'var(--theme-color-tab-background-active)',
+ 'tab-border': 'var(--theme-color-tab-border)',
+ 'icon-default': 'var(--theme-color-icon-default)',
+ 'icon-active': 'var(--theme-color-icon-active)',
+ 'menu-active': 'var(--theme-color-menu-active)',
+ 'input-border': 'var(--theme-color-input-border)',
+ 'input-border-active': 'var(--color-input-border-active)',
+ 'input-background': 'var(--theme-color-input-background)',
+ 'button-secondary-background': 'var(--theme-button-secondary-background)',
+ 'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',
+ 'button-secondary-border': 'var(--theme-color-card-border)',
+ 'row-separator': 'var(--theme-color-row-separator-background)',
+ 'row-heading-background': 'var(--theme-color-row-heading-background)',
+ 'row-heading-border': 'var(--theme-color-row-heading-border)',
+ accent: {
+ '50': 'rgba(var(--color-accent-50), )',
+ '100': 'rgba(var(--color-accent-100), )',
+ '200': 'rgba(var(--color-accent-200), )',
+ '300': 'rgba(var(--color-accent-300), )',
+ '400': 'rgba(var(--color-accent-400), )',
+ '500': 'rgba(var(--color-accent-500), )',
+ '600': 'rgba(var(--color-accent-600), )',
+ '700': 'rgba(var(--color-accent-700), )',
+ '800': 'rgba(var(--color-accent-800), )',
+ '900': 'rgba(var(--color-accent-900), )',
+ '950': 'rgba(var(--color-accent-950), )',
+ DEFAULT: 'var(--color-accent-default)',
+ foreground: 'var(--color-accent-foreground)',
+ },
+ 'button-primary-background': 'var(--theme-color-button-primary-background)',
+ 'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
+ 'button-primary-border': 'var(--theme-color-button-primary-border)',
+ 'button-primary-text': 'var(--theme-color-button-primary-text)',
+ 'input-select-active': 'var(--theme-color-input-select-active)',
+ 'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
+ foreground: 'var(--foreground)',
+ card: {
+ DEFAULT: 'var(--card))',
+ foreground: 'var(--card-foreground))',
+ },
+ popover: {
+ DEFAULT: 'var(--popover)',
+ foreground: 'var(--popover-foreground)',
+ border: 'var(--popover-border)',
+ },
+ destructive: {
+ DEFAULT: 'var(--destructive)',
+ foreground: 'var(--destructive-foreground)',
+ },
+ border: 'var(--border)',
+ input: 'var(--input)',
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))',
+ },
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+};
diff --git a/resources/js/utils/notification.ts b/resources/js/utils/notification.ts
index f6682187..fee4a9b1 100644
--- a/resources/js/utils/notification.ts
+++ b/resources/js/utils/notification.ts
@@ -81,7 +81,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
}
return response;
} catch {
- router.get(route('login'));
+ router.get('/login');
}
} else {
addNotification('error', 'The action failed. Please try again later.');
diff --git a/resources/js/utils/useTimeEntries.ts b/resources/js/utils/useTimeEntries.ts
index 09d4fe43..1f903980 100644
--- a/resources/js/utils/useTimeEntries.ts
+++ b/resources/js/utils/useTimeEntries.ts
@@ -1,225 +1,246 @@
import { defineStore } from 'pinia';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
-import { reactive, ref } from 'vue';
-import {
- api,
- type CreateTimeEntryBody,
- type TimeEntriesQueryParams,
- type TimeEntry,
+import { reactive, ref, type Ref } from 'vue';
+import { api } from '@/packages/api/src';
+import type {
+ CreateTimeEntryBody,
+ TimeEntriesQueryParams,
+ TimeEntry,
+ UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import dayjs from 'dayjs';
import { useNotificationsStore } from '@/utils/notification';
-import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
+import type {} from '@/packages/api/src';
import { useQueryClient } from '@tanstack/vue-query';
-export const useTimeEntriesStore = defineStore('timeEntries', () => {
- const timeEntries = ref(reactive([]));
+export const useTimeEntriesStore = defineStore(
+ 'timeEntries',
+ (): {
+ timeEntries: Ref;
+ fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise;
+ updateTimeEntry: (timeEntry: TimeEntry) => Promise;
+ createTimeEntry: (timeEntry: Omit) => Promise;
+ deleteTimeEntry: (timeEntryId: string) => Promise;
+ fetchMoreTimeEntries: () => Promise;
+ allTimeEntriesLoaded: Ref;
+ updateTimeEntries: (
+ ids: string[],
+ changes: UpdateMultipleTimeEntriesChangeset
+ ) => Promise;
+ deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise;
+ patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise;
+ } => {
+ const timeEntries = ref(reactive([]));
+
+ const allTimeEntriesLoaded = ref(false);
+ const { handleApiRequestNotifications } = useNotificationsStore();
+
+ const queryClient = useQueryClient();
+
+ async function patchTimeEntries(
+ queryParams: TimeEntriesQueryParams = {
+ only_full_dates: 'true',
+ member_id: getCurrentMembershipId(),
+ }
+ ) {
+ const organizationId = getCurrentOrganizationId();
- const allTimeEntriesLoaded = ref(false);
- const { handleApiRequestNotifications } = useNotificationsStore();
+ if (organizationId) {
+ const timeEntriesResponse = await handleApiRequestNotifications(
+ () =>
+ api.getTimeEntries({
+ params: {
+ organization: organizationId,
+ },
+ queries: queryParams,
+ }),
+ undefined,
+ 'Failed to fetch time entries'
+ );
+ if (timeEntriesResponse?.data) {
+ // insert missing time entries
+ const missingTimeEntries = timeEntriesResponse.data.filter(
+ (entry) => !timeEntries.value.find((e) => e.id === entry.id)
+ );
+ timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
+ }
+ }
+ }
- const queryClient = useQueryClient();
+ async function fetchTimeEntries(
+ queryParams: TimeEntriesQueryParams = {
+ only_full_dates: 'true',
+ member_id: getCurrentMembershipId(),
+ }
+ ) {
+ const organizationId = getCurrentOrganizationId();
- async function patchTimeEntries(
- queryParams: TimeEntriesQueryParams = {
- only_full_dates: 'true',
- member_id: getCurrentMembershipId(),
- }
- ) {
- const organizationId = getCurrentOrganizationId();
-
- if (organizationId) {
- const timeEntriesResponse = await handleApiRequestNotifications(
- () =>
- api.getTimeEntries({
- params: {
- organization: organizationId,
- },
- queries: queryParams,
- }),
- undefined,
- 'Failed to fetch time entries'
- );
- if (timeEntriesResponse?.data) {
- // insert missing time entries
- const missingTimeEntries = timeEntriesResponse.data.filter(
- (entry) => !timeEntries.value.find((e) => e.id === entry.id)
+ if (organizationId) {
+ const timeEntriesResponse = await handleApiRequestNotifications(
+ () =>
+ api.getTimeEntries({
+ params: {
+ organization: organizationId,
+ },
+ queries: queryParams,
+ }),
+ undefined,
+ 'Failed to fetch time entries'
);
- timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
+ if (timeEntriesResponse?.data) {
+ timeEntries.value = timeEntriesResponse.data;
+ }
}
}
- }
- async function fetchTimeEntries(
- queryParams: TimeEntriesQueryParams = {
- only_full_dates: 'true',
- member_id: getCurrentMembershipId(),
- }
- ) {
- const organizationId = getCurrentOrganizationId();
-
- if (organizationId) {
- const timeEntriesResponse = await handleApiRequestNotifications(
- () =>
- api.getTimeEntries({
- params: {
- organization: organizationId,
- },
- queries: queryParams,
- }),
- undefined,
- 'Failed to fetch time entries'
- );
- if (timeEntriesResponse?.data) {
- timeEntries.value = timeEntriesResponse.data;
+ async function fetchMoreTimeEntries() {
+ const organizationId = getCurrentOrganizationId();
+ if (organizationId) {
+ const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
+ dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
+
+ const timeEntriesResponse = await handleApiRequestNotifications(
+ () =>
+ api.getTimeEntries({
+ params: {
+ organization: organizationId,
+ },
+ queries: {
+ only_full_dates: 'true',
+ member_id: getCurrentMembershipId(),
+ end: dayjs(latestTimeEntry.start).utc().format(),
+ },
+ }),
+ undefined,
+ 'Failed to fetch time entries'
+ );
+ if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
+ timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
+ } else {
+ allTimeEntriesLoaded.value = true;
+ }
}
}
- }
- async function fetchMoreTimeEntries() {
- const organizationId = getCurrentOrganizationId();
- if (organizationId) {
- const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
- dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
-
- const timeEntriesResponse = await handleApiRequestNotifications(
- () =>
- api.getTimeEntries({
- params: {
- organization: organizationId,
- },
- queries: {
- only_full_dates: 'true',
- member_id: getCurrentMembershipId(),
- end: dayjs(latestTimeEntry.start).utc().format(),
- },
- }),
- undefined,
- 'Failed to fetch time entries'
- );
- if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
- timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
- } else {
- allTimeEntriesLoaded.value = true;
+ async function updateTimeEntries(
+ ids: string[],
+ changes: UpdateMultipleTimeEntriesChangeset
+ ) {
+ const organizationId = getCurrentOrganizationId();
+ if (organizationId) {
+ await handleApiRequestNotifications(
+ () =>
+ api.updateMultipleTimeEntries(
+ {
+ ids: ids,
+ changes: changes,
+ },
+ {
+ params: {
+ organization: organizationId,
+ },
+ }
+ ),
+ 'Time entries updated successfully',
+ 'Failed to update time entries'
+ );
}
}
- }
- async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
- const organizationId = getCurrentOrganizationId();
- if (organizationId) {
- await handleApiRequestNotifications(
- () =>
- api.updateMultipleTimeEntries(
- {
- ids: ids,
- changes: changes,
- },
- {
+ async function updateTimeEntry(timeEntry: TimeEntry) {
+ const organizationId = getCurrentOrganizationId();
+ if (organizationId) {
+ const response = await handleApiRequestNotifications(
+ () =>
+ api.updateTimeEntry(timeEntry, {
params: {
organization: organizationId,
+ timeEntry: timeEntry.id,
},
- }
- ),
- 'Time entries updated successfully',
- 'Failed to update time entries'
- );
+ }),
+ 'Time entry updated successfully',
+ 'Failed to update time entry'
+ );
+ timeEntries.value = timeEntries.value.map((entry) =>
+ entry.id === timeEntry.id ? response.data : entry
+ );
+ queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
+ }
}
- }
- async function updateTimeEntry(timeEntry: TimeEntry) {
- const organizationId = getCurrentOrganizationId();
- if (organizationId) {
- const response = await handleApiRequestNotifications(
- () =>
- api.updateTimeEntry(timeEntry, {
- params: {
- organization: organizationId,
- timeEntry: timeEntry.id,
- },
- }),
- 'Time entry updated successfully',
- 'Failed to update time entry'
- );
- timeEntries.value = timeEntries.value.map((entry) =>
- entry.id === timeEntry.id ? response.data : entry
- );
- queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
+ async function createTimeEntry(timeEntry: Omit) {
+ const organizationId = getCurrentOrganizationId();
+ const memberId = getCurrentMembershipId();
+ if (organizationId && memberId !== undefined) {
+ const newTimeEntry = {
+ ...timeEntry,
+ member_id: memberId,
+ } as CreateTimeEntryBody;
+ await handleApiRequestNotifications(
+ () =>
+ api.createTimeEntry(newTimeEntry, {
+ params: {
+ organization: organizationId,
+ },
+ }),
+ 'Time entry created successfully',
+ 'Failed to create time entry'
+ );
+ await fetchTimeEntries();
+ }
}
- }
- async function createTimeEntry(timeEntry: Omit) {
- const organizationId = getCurrentOrganizationId();
- const memberId = getCurrentMembershipId();
- if (organizationId && memberId !== undefined) {
- const newTimeEntry = {
- ...timeEntry,
- member_id: memberId,
- } as CreateTimeEntryBody;
- await handleApiRequestNotifications(
- () =>
- api.createTimeEntry(newTimeEntry, {
- params: {
- organization: organizationId,
- },
- }),
- 'Time entry created successfully',
- 'Failed to create time entry'
- );
- await fetchTimeEntries();
+ async function deleteTimeEntry(timeEntryId: string) {
+ const organizationId = getCurrentOrganizationId();
+ if (organizationId) {
+ await handleApiRequestNotifications(
+ () =>
+ api.deleteTimeEntry(undefined, {
+ params: {
+ organization: organizationId,
+ timeEntry: timeEntryId,
+ },
+ }),
+ 'Time entry deleted successfully',
+ 'Failed to delete time entry'
+ );
+ await fetchTimeEntries();
+ }
}
- }
- async function deleteTimeEntry(timeEntryId: string) {
- const organizationId = getCurrentOrganizationId();
- if (organizationId) {
- await handleApiRequestNotifications(
- () =>
- api.deleteTimeEntry(undefined, {
- params: {
- organization: organizationId,
- timeEntry: timeEntryId,
- },
- }),
- 'Time entry deleted successfully',
- 'Failed to delete time entry'
- );
- await fetchTimeEntries();
+ async function deleteTimeEntries(timeEntries: TimeEntry[]) {
+ const organizationId = getCurrentOrganizationId();
+ const timeEntryIds = timeEntries.map((entry) => entry.id);
+ if (organizationId) {
+ await handleApiRequestNotifications(
+ () =>
+ api.deleteTimeEntries(undefined, {
+ queries: {
+ ids: timeEntryIds,
+ },
+ params: {
+ organization: organizationId,
+ },
+ }),
+ 'Time entries deleted successfully',
+ 'Failed to delete time entries'
+ );
+ await fetchTimeEntries();
+ }
}
- }
- async function deleteTimeEntries(timeEntries: TimeEntry[]) {
- const organizationId = getCurrentOrganizationId();
- const timeEntryIds = timeEntries.map((entry) => entry.id);
- if (organizationId) {
- await handleApiRequestNotifications(
- () =>
- api.deleteTimeEntries(undefined, {
- queries: {
- ids: timeEntryIds,
- },
- params: {
- organization: organizationId,
- },
- }),
- 'Time entries deleted successfully',
- 'Failed to delete time entries'
- );
- await fetchTimeEntries();
- }
+ return {
+ timeEntries,
+ fetchTimeEntries,
+ updateTimeEntry,
+ createTimeEntry,
+ deleteTimeEntry,
+ fetchMoreTimeEntries,
+ allTimeEntriesLoaded,
+ updateTimeEntries,
+ deleteTimeEntries,
+ patchTimeEntries,
+ };
}
-
- return {
- timeEntries,
- fetchTimeEntries,
- updateTimeEntry,
- createTimeEntry,
- deleteTimeEntry,
- fetchMoreTimeEntries,
- allTimeEntriesLoaded,
- updateTimeEntries,
- deleteTimeEntries,
- patchTimeEntries,
- };
-});
+);
diff --git a/tailwind.config.js b/tailwind.config.js
index e8b66399..1cd1952e 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,6 +1,7 @@
import defaultTheme from 'tailwindcss/defaultTheme';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
+import { solidtimeTheme } from './resources/js/packages/ui/tailwind.theme.js';
/** @type {import("tailwindcss").Config} */
export default {
@@ -16,130 +17,10 @@ export default {
],
theme: {
extend: {
- boxShadow: {
- card: 'var(--theme-shadow-card)',
- dropdown: 'var(--theme-shadow-dropdown)',
- },
- containers: {
- '2xs': '16rem',
- },
+ ...solidtimeTheme,
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
- fontSize: {
- xs: ['0.75rem', { lineHeight: '1rem' }],
- sm: ['0.8125rem', { lineHeight: '1.125rem' }],
- base: ['0.875rem', { lineHeight: '1.25rem' }],
- lg: ['1rem', { lineHeight: '1.5rem' }],
- xl: ['1.125rem', { lineHeight: '1.75rem' }],
- '2xl': ['1.25rem', { lineHeight: '1.75rem' }],
- '3xl': ['1.5rem', { lineHeight: '2rem' }],
- '4xl': ['1.75rem', { lineHeight: '2.25rem' }],
- '5xl': ['2rem', { lineHeight: '1' }],
- '6xl': ['2.25rem', { lineHeight: '1' }],
- '7xl': ['2.5rem', { lineHeight: '1' }],
- '8xl': ['3rem', { lineHeight: '1' }],
- '9xl': ['3.5rem', { lineHeight: '1' }],
- },
- colors: {
- ring: 'var(--ring)',
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
- },
- tertiary: 'var(--color-bg-tertiary)',
- quaternary: 'var(--color-bg-quaternary)',
- background: 'var(--background)',
- 'text-primary': 'var(--color-text-primary)',
- 'text-secondary': 'var(--color-text-secondary)',
- 'text-tertiary': 'var(--color-text-tertiary)',
- 'text-quaternary': 'var(--color-text-quaternary)',
- 'border-primary': 'var(--color-border-primary)',
- 'border-secondary': 'var(--color-border-secondary)',
- 'border-tertiary': 'var(--color-border-tertiary)',
- 'default-background': 'var(--theme-color-default-background)',
- 'default-background-separator': 'var(--theme-color-default-background-separator)',
- 'row-background': 'var(--theme-color-row-background)',
- 'card-background': 'var(--theme-color-card-background)',
- 'card-background-active': 'var(--theme-color-card-background-active)',
- 'card-background-separator': 'var(--theme-color-card-background-separator)',
- 'card-border': 'var(--theme-color-card-border)',
- 'card-border-active': 'var(--theme-color-card-border-active)',
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
- },
- 'tab-background': 'var(--theme-color-tab-background)',
- 'tab-background-active': 'var(--theme-color-tab-background-active)',
- 'tab-border': 'var(--theme-color-tab-border)',
- 'icon-default': 'var(--theme-color-icon-default)',
- 'icon-active': 'var(--theme-color-icon-active)',
- 'menu-active': 'var(--theme-color-menu-active)',
- 'input-border': 'var(--theme-color-input-border)',
- 'input-border-active': 'var(--color-input-border-active)',
- 'input-background': 'var(--theme-color-input-background)',
- 'button-secondary-background': 'var(--theme-button-secondary-background)',
- 'button-secondary-background-hover':
- 'var(--theme-button-secondary-background-active)',
- 'button-secondary-border': 'var(--theme-color-card-border)',
- 'row-separator': 'var(--theme-color-row-separator-background)',
- 'row-heading-background': 'var(--theme-color-row-heading-background)',
- 'row-heading-border': 'var(--theme-color-row-heading-border)',
- accent: {
- '50': 'rgba(var(--color-accent-50), )',
- '100': 'rgba(var(--color-accent-100), )',
- '200': 'rgba(var(--color-accent-200), )',
- '300': 'rgba(var(--color-accent-300), )',
- '400': 'rgba(var(--color-accent-400), )',
- '500': 'rgba(var(--color-accent-500), )',
- '600': 'rgba(var(--color-accent-600), )',
- '700': 'rgba(var(--color-accent-700), )',
- '800': 'rgba(var(--color-accent-800), )',
- '900': 'rgba(var(--color-accent-900), )',
- '950': 'rgba(var(--color-accent-950), )',
- DEFAULT: 'var(--color-accent-default)',
- foreground: 'var(--color-accent-foreground)',
- },
- 'button-primary-background': 'var(--theme-color-button-primary-background)',
- 'button-primary-background-hover':
- 'var(--theme-color-button-primary-background-hover)',
- 'button-primary-border': 'var(--theme-color-button-primary-border)',
- 'button-primary-text': 'var(--theme-color-button-primary-text)',
- 'input-select-active': 'var(--theme-color-input-select-active)',
- 'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
- foreground: 'var(--foreground)',
- card: {
- DEFAULT: 'var(--card))',
- foreground: 'var(--card-foreground))',
- },
- popover: {
- DEFAULT: 'var(--popover)',
- foreground: 'var(--popover-foreground)',
- border: 'var(--popover-border)',
- },
- destructive: {
- DEFAULT: 'var(--destructive)',
- foreground: 'var(--destructive-foreground)',
- },
- border: 'var(--border)',
- input: 'var(--input)',
- chart: {
- '1': 'hsl(var(--chart-1))',
- '2': 'hsl(var(--chart-2))',
- '3': 'hsl(var(--chart-3))',
- '4': 'hsl(var(--chart-4))',
- '5': 'hsl(var(--chart-5))',
- },
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
- },
},
},
diff --git a/tsconfig.json b/tsconfig.json
index ca98f8c8..cb9f8252 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,6 +6,7 @@
"compilerOptions": {
"paths": {
"@/*": ["./resources/js/*"],
+ "@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"]
}
},
"skipLibCheck": true,