From a15429334817465b8b182d1a3a484871aeb03e55 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 5 Feb 2026 15:02:01 +0100 Subject: [PATCH] migrate datepickers to shadcn, Fixes #877, #807 --- e2e/shared-reports.spec.ts | 126 ++++++++ e2e/time.spec.ts | 284 +++++++++++++++++- e2e/timetracker.spec.ts | 60 ++++ e2e/utils/reporting.ts | 2 + .../Common/Report/ReportCreateModal.vue | 9 +- .../Common/Report/ReportEditModal.vue | 17 +- .../ui/calendar/CalendarCellTrigger.vue | 2 +- .../js/packages/ui/src/Input/DatePicker.vue | 119 ++++---- .../packages/ui/src/Input/DateRangePicker.vue | 12 +- .../ui/src/Input/TimeRangeSelector.vue | 27 +- .../ui/src/TimeEntry/TimeEntryCreateModal.vue | 20 +- .../ui/src/TimeEntry/TimeEntryEditModal.vue | 21 +- .../RangeCalendarCellTrigger.vue | 4 +- resources/js/packages/ui/src/utils/time.ts | 3 + 14 files changed, 613 insertions(+), 93 deletions(-) diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts index db82235d..27605619 100644 --- a/e2e/shared-reports.spec.ts +++ b/e2e/shared-reports.spec.ts @@ -19,6 +19,10 @@ import { // Each test registers a new user and creates test data, which needs more time test.describe.configure({ timeout: 60000 }); +// Date picker button name patterns for different date formats +const DATE_PICKER_BUTTON_PATTERN = + /^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; + // ────────────────────────────────────────────────── // Shared Report Lifecycle Tests // ────────────────────────────────────────────────── @@ -203,6 +207,128 @@ test('test that shared report with No Task filter shows entries without a task', await expect(page.getByText('Total')).toBeVisible(); }); +// ────────────────────────────────────────────────── +// Report Date Picker Tests +// ────────────────────────────────────────────────── + +test('test that creating a report with an expiration date works', async ({ page }) => { + const projectName = 'DatePickerProj ' + Math.floor(Math.random() * 10000); + const reportName = 'DatePickerReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Open the save report modal + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Name').fill(reportName); + + // The "Public" checkbox should be checked by default, showing the date picker + const datePicker = page + .getByRole('dialog') + .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }); + await expect(datePicker).toBeVisible(); + await datePicker.click(); + + // Select a date in the next month + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: /Next/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click(); + + // Wait for the calendar to close + await expect(calendarGrid).not.toBeVisible(); + + // Create the report and verify it includes the public_until date + const [response] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), + ]); + const responseBody = await response.json(); + expect(responseBody.data.public_until).toBeTruthy(); +}); + +test('test that editing a report to make it public with expiration date works', async ({ + page, +}) => { + const projectName = 'EditDateProj ' + Math.floor(Math.random() * 10000); + const reportName = 'EditDateReport ' + Math.floor(Math.random() * 10000); + + await createProject(page, projectName); + await createTimeEntryWithProject(page, projectName, '1h'); + + await goToReporting(page); + await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible(); + + // Open the save report modal and create a private report + await page.getByRole('button', { name: 'Save Report' }).click(); + await page.getByLabel('Name').fill(reportName); + + // Uncheck "Public" to create a private report + await page.getByLabel('Public').click(); + + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), + ]); + + // Go to shared reports and edit + await goToReportingShared(page); + await expect(page.getByText(reportName)).toBeVisible(); + await expect(page.getByText('Private')).toBeVisible(); + + // Click more options and edit + await page + .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) }) + .click(); + await page.getByRole('menuitem', { name: /^Edit Report/ }).click(); + + // Check "Public" to make it public - this should show the date picker + await page.getByLabel('Public').click(); + + // The date picker should now be visible + const datePicker = page + .getByRole('dialog') + .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }); + await expect(datePicker).toBeVisible(); + await datePicker.click(); + + // Select a date in the next month + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + await page.getByRole('button', { name: /Next/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click(); + + // Wait for the calendar to close + await expect(calendarGrid).not.toBeVisible(); + + // Update the report and verify it includes the public_until date + const [response] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/reports/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Report' }).click(), + ]); + const responseBody = await response.json(); + expect(responseBody.data.public_until).toBeTruthy(); + expect(responseBody.data.is_public).toBe(true); +}); + test('test that shared report with No Client filter shows entries without a client', async ({ page, }) => { diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index 0776790c..06c7f2eb 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -11,10 +11,37 @@ import { } from './utils/currentTimeEntry'; import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting'; +// Date picker button name patterns for different date formats +// Matches: "Pick a date", "YYYY-MM-DD", "DD/MM/YYYY", "DD.MM.YYYY", "MM/DD/YYYY", "DD-MM-YYYY", "MM-DD-YYYY" +const DATE_PICKER_BUTTON_PATTERN = + /^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; +// Same pattern but without "Pick a date" - for when we expect an actual date to be displayed +const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; + +/** + * Extracts day of month from an ISO timestamp string + */ +function getDayFromTimestamp(timestamp: string): number { + return new Date(timestamp).getUTCDate(); +} + +/** + * Extracts month (1-indexed) from an ISO timestamp string + */ +function getMonthFromTimestamp(timestamp: string): number { + return new Date(timestamp).getUTCMonth() + 1; +} + async function goToTimeOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/time'); } +async function goToOrganizationSettings(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); + await page.locator('[data-testid="organization_switcher"]:visible').click(); + await page.getByText('Organization Settings').click(); +} + async function createEmptyTimeEntry(page: Page) { await Promise.all([ newTimeEntryResponse(page), @@ -310,7 +337,262 @@ test.skip('test that load more works when the end of page is reached', async ({ // TODO: Test Grouped time entries by description/project -// TODO: Add Test for Date Update +// Date Update Tests + +test('test that updating the start date of a time entry via the edit modal works', async ({ + page, +}) => { + await createBareTimeEntry(page, 'Date edit test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + + // Open edit modal via the actions dropdown + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Click the start date picker (first date picker button in the Start section) + const startDatePicker = page + .getByRole('dialog') + .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }) + .first(); + await startDatePicker.click(); + + // Navigate to the previous month and select the 15th + await page.getByRole('button', { name: /Previous/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click(); + + // Get current month to calculate expected month after going to previous + const now = new Date(); + const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed) + + // Submit the update and verify the response has correct date + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.start).toBeTruthy(); + expect(updateBody.data.end).toBeTruthy(); + // Verify the day was changed to 15th + expect(getDayFromTimestamp(updateBody.data.start)).toBe(15); + // Verify the month is the previous month + expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth); +}); + +test('test that setting a date in the create modal works', async ({ page }) => { + await goToTimeOverview(page); + + // Get today's date to compare later + const today = new Date(); + + // Open create modal + await page.getByRole('button', { name: 'Time entry actions' }).click(); + await page.getByRole('menuitem', { name: 'Manual time entry' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Set description + await page + .getByRole('dialog') + .getByRole('textbox', { name: 'Description' }) + .fill('Date picker test entry'); + + // Set duration first (to ensure the form is valid) + await page.locator('[role="dialog"] input[name="Duration"]').fill('1h'); + await page.locator('[role="dialog"] input[name="Duration"]').press('Tab'); + + // Click the start date picker + const startDatePicker = page + .getByRole('dialog') + .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN }) + .first(); + await startDatePicker.click(); + + // Wait for calendar to appear + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + + // Navigate to previous month and select the 15th (a day that's always in the middle of the month) + await page.getByRole('button', { name: /Previous/i }).click(); + await page.getByRole('gridcell', { name: '15' }).getByRole('button').click(); + + // Wait for calendar to close + await expect(calendarGrid).not.toBeVisible(); + + // Get current month to calculate expected month after going to previous + const expectedMonth = today.getMonth() === 0 ? 12 : today.getMonth(); // Previous month (1-indexed) + + // Submit and verify creation succeeds with correct date + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes('/time-entries') && response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + const createBody = await createResponse.json(); + expect(createBody.data.start).toBeTruthy(); + // Verify the day was set to 15th + expect(getDayFromTimestamp(createBody.data.start)).toBe(15); + // Verify the month is the previous month + expect(getMonthFromTimestamp(createBody.data.start)).toBe(expectedMonth); +}); + +test('test that updating the date via the time entry row range selector works', async ({ + page, +}) => { + await createBareTimeEntry(page, 'Date range test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + await expect(newTimeEntry).toBeVisible(); + + // Open the time range popover + const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector'); + await timeEntryRangeElement.click(); + + // Verify the range selector dropdown is open + const rangeStart = page.getByTestId('time_entry_range_start'); + await expect(rangeStart).toBeVisible(); + + // Click the start date picker button within the range selector + const startDatePicker = page.getByRole('button', { name: DATE_DISPLAY_PATTERN }).first(); + await expect(startDatePicker).toBeVisible(); + await startDatePicker.click(); + + // Wait for the calendar to appear and select a day + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + + // Navigate to previous month and select the 5th + await page.getByRole('button', { name: /Previous/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^5$/ }).first().click(); + + // Get current month to calculate expected month after going to previous + const now = new Date(); + const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed) + + // Verify the time entry update API call succeeds with correct date + const updateResponse = await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + response.request().method() === 'PUT' && + (await response.headerValue('Content-Type')) === 'application/json' + ); + }); + const updateBody = await updateResponse.json(); + expect(updateBody.data.start).toBeTruthy(); + // Verify the day was changed to 5th + expect(getDayFromTimestamp(updateBody.data.start)).toBe(5); + // Verify the month is the previous month + expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth); +}); + +test('test that updating the end date via the time entry row range selector works', async ({ + page, +}) => { + await createBareTimeEntry(page, 'End date range test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + await expect(newTimeEntry).toBeVisible(); + + // Open the time range popover + const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector'); + await timeEntryRangeElement.click(); + + // Verify the range selector dropdown is open + const rangeEnd = page.getByTestId('time_entry_range_end'); + await expect(rangeEnd).toBeVisible(); + + // Click the end date picker button (second date picker) + const datePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN }); + const endDatePicker = datePickers.nth(1); + await expect(endDatePicker).toBeVisible(); + await endDatePicker.click(); + + // Wait for the calendar to appear + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + + // Navigate to next month and select the 20th (to ensure end > start) + await page.getByRole('button', { name: /Next/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click(); + + // Get current month to calculate expected month after going to next + const now = new Date(); + const expectedMonth = now.getMonth() === 11 ? 1 : now.getMonth() + 2; // Next month (1-indexed) + + // Verify the time entry update API call succeeds with correct date + const updateResponse = await page.waitForResponse(async (response) => { + return ( + response.status() === 200 && + response.request().method() === 'PUT' && + (await response.headerValue('Content-Type')) === 'application/json' + ); + }); + const updateBody = await updateResponse.json(); + expect(updateBody.data.end).toBeTruthy(); + // Verify the day was changed to 20th + expect(getDayFromTimestamp(updateBody.data.end)).toBe(20); + // Verify the month is the next month + expect(getMonthFromTimestamp(updateBody.data.end)).toBe(expectedMonth); +}); + +test('test that date picker displays date in organization date format', async ({ page }) => { + // First change the organization date format to DD/MM/YYYY + await goToOrganizationSettings(page); + await page.getByLabel('Date Format').click(); + await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Date Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy' + ), + ]); + + // Create a time entry and open the edit modal + await createBareTimeEntry(page, 'Date format test', '1h'); + await goToTimeOverview(page); + + const timeEntryRows = page.locator('[data-testid="time_entry_row"]'); + const newTimeEntry = timeEntryRows.first(); + await expect(newTimeEntry).toBeVisible(); + + // Open edit modal + const actionsDropdown = newTimeEntry + .getByRole('button', { name: 'Actions for the time entry' }) + .first(); + await actionsDropdown.click(); + await page.getByTestId('time_entry_edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Verify the date picker shows the date in DD/MM/YYYY format + const datePicker = page + .getByRole('dialog') + .getByRole('button', { name: /^\d{2}\/\d{2}\/\d{4}$/ }) + .first(); + await expect(datePicker).toBeVisible(); +}); // TODO: Test that project can be created in the time entry row diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts index ef4063d2..7b35ce65 100644 --- a/e2e/timetracker.spec.ts +++ b/e2e/timetracker.spec.ts @@ -10,6 +10,9 @@ import { import type { Page } from '@playwright/test'; import { newTagResponse } from './utils/tags'; +// Date picker button name patterns for different date formats +const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; + async function goToDashboard(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard'); } @@ -254,6 +257,63 @@ test('test that adding a new tag when the timer is running', async ({ page }) => await assertThatTimerIsStopped(page); }); +test('test that setting an end time with a different date via the timetracker range selector works', async ({ + page, +}) => { + await goToDashboard(page); + + // Start a timer + await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]); + await assertThatTimerHasStarted(page); + + // Open the time range dropdown by clicking on the time display + await page.getByTestId('time_entry_time').click(); + const rangeStart = page.getByTestId('time_entry_range_start'); + await expect(rangeStart).toBeVisible(); + + // Click "Set End Time" button + await page.getByRole('button', { name: 'Set End Time' }).click(); + + // The end time picker should now be visible with a Confirm button + const rangeEnd = page.getByTestId('time_entry_range_end'); + await expect(rangeEnd).toBeVisible(); + const confirmButton = page.getByRole('button', { name: 'Confirm' }); + await expect(confirmButton).toBeVisible(); + + // Click the end date picker to change the date + const endDatePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN }); + // The second date picker is the end date (first is the start date) + const endDatePicker = endDatePickers.nth(1); + await expect(endDatePicker).toBeVisible(); + await endDatePicker.click(); + + // Calendar should appear + const calendarGrid = page.getByRole('grid'); + await expect(calendarGrid).toBeVisible({ timeout: 5000 }); + + // Navigate to the next month and select a day to ensure end > start + await page.getByRole('button', { name: /Next/i }).click(); + await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click(); + + // The dropdown should still be open after selecting a date (not auto-closed) + await expect(rangeEnd).toBeVisible(); + await expect(confirmButton).toBeVisible(); + + // Click Confirm to finalize and verify the API call + const [updateResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + confirmButton.click(), + ]); + const updateBody = await updateResponse.json(); + expect(updateBody.data.start).toBeTruthy(); + expect(updateBody.data.end).toBeTruthy(); +}); + // test that search is working // test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts index acf80ae1..7e7bf973 100644 --- a/e2e/utils/reporting.ts +++ b/e2e/utils/reporting.ts @@ -314,5 +314,7 @@ export async function saveAsSharedReport( page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(), ]); const responseBody = await response.json(); + // Wait for navigation to shared reports page + await page.waitForURL('**/reporting/shared'); return { shareableLink: responseBody.data.shareable_link }; } diff --git a/resources/js/Components/Common/Report/ReportCreateModal.vue b/resources/js/Components/Common/Report/ReportCreateModal.vue index dd08f03c..62cfa345 100644 --- a/resources/js/Components/Common/Report/ReportCreateModal.vue +++ b/resources/js/Components/Common/Report/ReportCreateModal.vue @@ -12,6 +12,8 @@ import { api } from '@/packages/api/src'; import { Checkbox } from '@/packages/ui/src'; import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import { useNotificationsStore } from '@/utils/notification'; +import { getDayJsInstance } from '@/packages/ui/src/utils/time'; +import { router } from '@inertiajs/vue3'; const show = defineModel('show', { default: false }); const saving = ref(false); @@ -44,10 +46,14 @@ const report = ref({ const { handleApiRequestNotifications } = useNotificationsStore(); async function submit() { + const publicUntil = report.value.public_until + ? getDayJsInstance()(report.value.public_until).utc().format() + : null; await handleApiRequestNotifications( () => createReportMutation.mutateAsync({ ...report.value, + public_until: publicUntil, properties: { ...props.properties }, }), 'Success', @@ -60,6 +66,7 @@ async function submit() { public_until: null, }; show.value = false; + router.visit(route('reporting.shared')); } ); } @@ -97,7 +104,7 @@ async function submit() {
(optional)
- + diff --git a/resources/js/Components/Common/Report/ReportEditModal.vue b/resources/js/Components/Common/Report/ReportEditModal.vue index 49348825..7801f885 100644 --- a/resources/js/Components/Common/Report/ReportEditModal.vue +++ b/resources/js/Components/Common/Report/ReportEditModal.vue @@ -2,7 +2,7 @@ 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 { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue'; import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue'; import type { UpdateReportBody } from '@/packages/api/src'; @@ -13,6 +13,7 @@ import { Checkbox } from '@/packages/ui/src'; import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import { useNotificationsStore } from '@/utils/notification'; import type { Report } from '@/packages/api/src'; +import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; const show = defineModel('show', { default: false }); const saving = ref(false); @@ -61,9 +62,21 @@ watch( } ); +// Intermediate local variable for DatePicker (converts between UTC and localized) +const localPublicUntil = computed({ + get: () => { + if (!report.value.public_until) return null; + return getLocalizedDayJs(report.value.public_until).format(); + }, + set: (value: string | null) => { + report.value.public_until = value ? getDayJsInstance()(value).utc().format() : null; + }, +}); + const { handleApiRequestNotifications } = useNotificationsStore(); async function submit() { + // public_until is already in UTC format from the computed setter await handleApiRequestNotifications( () => updateReportMutation.mutateAsync(report.value), 'Success', @@ -111,7 +124,7 @@ async function submit() {
- +
diff --git a/resources/js/Components/ui/calendar/CalendarCellTrigger.vue b/resources/js/Components/ui/calendar/CalendarCellTrigger.vue index 9df66f0e..163906c1 100644 --- a/resources/js/Components/ui/calendar/CalendarCellTrigger.vue +++ b/resources/js/Components/ui/calendar/CalendarCellTrigger.vue @@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps); 'h-8 w-8 p-0 font-normal', '[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground', // Selected - 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', + 'data-[selected]:bg-quaternary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-quaternary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', // Disabled 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', // Unavailable diff --git a/resources/js/packages/ui/src/Input/DatePicker.vue b/resources/js/packages/ui/src/Input/DatePicker.vue index 747ec8f9..58701709 100644 --- a/resources/js/packages/ui/src/Input/DatePicker.vue +++ b/resources/js/packages/ui/src/Input/DatePicker.vue @@ -1,11 +1,21 @@ - - diff --git a/resources/js/packages/ui/src/Input/DateRangePicker.vue b/resources/js/packages/ui/src/Input/DateRangePicker.vue index 5c607056..f3abba85 100644 --- a/resources/js/packages/ui/src/Input/DateRangePicker.vue +++ b/resources/js/packages/ui/src/Input/DateRangePicker.vue @@ -6,11 +6,18 @@ import { CalendarDate } from '@internationalized/date'; import { CalendarIcon } from 'lucide-vue-next'; import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { twMerge } from 'tailwind-merge'; -import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; +import { + getDayJsInstance, + getLocalizedDayJs, + firstDayIndex, + type WeekStartDay, +} from '@/packages/ui/src/utils/time'; import { type Organization } from '@/packages/api/src'; import { getUserTimezone } from '@/packages/ui/src/utils/settings'; import { formatDate } from '@/packages/ui/src/utils/time'; +const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay); + const props = defineProps<{ start: string; end: string; @@ -207,7 +214,8 @@ watch(open, (value) => { v-model="modelValue" initial-focus :number-of-months="2" - :max-value="today" /> + :max-value="today" + :week-starts-on="weekStartsOn" /> diff --git a/resources/js/packages/ui/src/Input/TimeRangeSelector.vue b/resources/js/packages/ui/src/Input/TimeRangeSelector.vue index 545109f1..9a4861a4 100644 --- a/resources/js/packages/ui/src/Input/TimeRangeSelector.vue +++ b/resources/js/packages/ui/src/Input/TimeRangeSelector.vue @@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue'; import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time'; import dayjs from 'dayjs'; import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue'; -import Button from '../Buttons/Button.vue'; +import { Button } from '@/Components/ui/button'; const props = defineProps<{ start: string; @@ -61,9 +61,10 @@ const dropdownContent = ref(); class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
Start
-
+
+ class="w-full" + @changed="updateTimeEntry">
End
-
+
-
+
- + diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue index f229716e..30d8757d 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryCreateModal.vue @@ -235,22 +235,22 @@ const billableProxy = computed({
Start -
- - + + class="w-full" + size="large"> +
End -
- - + + class="w-full" + size="large"> +
diff --git a/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue b/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue index b148e25b..35e7aed7 100644 --- a/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue +++ b/resources/js/packages/ui/src/TimeEntry/TimeEntryEditModal.vue @@ -251,22 +251,25 @@ const billableProxy = computed({
Start -
- +
+ + class="w-full" + tabindex="1">
End -
- - + + class="w-full" + size="large"> +
diff --git a/resources/js/packages/ui/src/range-calendar/RangeCalendarCellTrigger.vue b/resources/js/packages/ui/src/range-calendar/RangeCalendarCellTrigger.vue index 286f7944..9a637dda 100644 --- a/resources/js/packages/ui/src/range-calendar/RangeCalendarCellTrigger.vue +++ b/resources/js/packages/ui/src/range-calendar/RangeCalendarCellTrigger.vue @@ -26,9 +26,9 @@ const forwardedProps = useForwardProps(delegatedProps); 'h-8 w-8 p-0 font-normal data-[selected]:opacity-100', '[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground', // Selection Start - 'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground', + 'data-[selection-start]:bg-quaternary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-quaternary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground', // Selection End - 'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground', + 'data-[selection-end]:bg-quaternary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-quaternary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground', // Outside months 'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30', // Disabled diff --git a/resources/js/packages/ui/src/utils/time.ts b/resources/js/packages/ui/src/utils/time.ts index 2685a102..e55883f9 100644 --- a/resources/js/packages/ui/src/utils/time.ts +++ b/resources/js/packages/ui/src/utils/time.ts @@ -21,6 +21,9 @@ export type DateFormat = | 'hyphen-separated-mm-dd-yyyy' | 'hyphen-separated-yyyy-mm-dd'; +// Day of week index type for calendar components (0 = Sunday, 6 = Saturday) +export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; + const dateFormatMap: Record = { 'point-separated-d-m-yyyy': 'D.M.YYYY', 'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',