mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 01:03:16 +01:00
Compare commits
1 Commits
project-ta
...
8f3c73f266
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3c73f266 |
@@ -53,47 +53,6 @@ function todayAt(hour: number, minute: number = 0): string {
|
|||||||
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
|
|
||||||
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
|
|
||||||
// whichever day the suite runs on.
|
|
||||||
function wednesdayAt(hour: number, minute: number = 0): Date {
|
|
||||||
const now = new Date();
|
|
||||||
const daysSinceMonday = (now.getDay() + 6) % 7;
|
|
||||||
return new Date(
|
|
||||||
now.getFullYear(),
|
|
||||||
now.getMonth(),
|
|
||||||
now.getDate() - daysSinceMonday + 2,
|
|
||||||
hour,
|
|
||||||
minute,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function thursdayAt(hour: number, minute: number = 0): Date {
|
|
||||||
const d = wednesdayAt(hour, minute);
|
|
||||||
d.setDate(d.getDate() + 1);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toApiTimestamp(date: Date): string {
|
|
||||||
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
|
|
||||||
function toDateStr(date: Date): string {
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
|
|
||||||
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
|
|
||||||
await expect(header).toBeVisible();
|
|
||||||
const box = await header.boundingBox();
|
|
||||||
return box!.x + box!.width / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests verify that changing the project on a time entry via the calendar
|
* These tests verify that changing the project on a time entry via the calendar
|
||||||
* updates the billable status to match the new project's is_billable setting.
|
* updates the billable status to match the new project's is_billable setting.
|
||||||
@@ -1003,9 +962,14 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
|
const now = new Date();
|
||||||
const start = toApiTimestamp(thursdayAt(0, 30));
|
const dayOfWeek = now.getDay();
|
||||||
const end = toApiTimestamp(thursdayAt(1, 30));
|
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
|
||||||
|
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
|
||||||
|
|
||||||
|
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
|
||||||
|
const start = todayAt(0, 30);
|
||||||
|
const end = todayAt(1, 30);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Drag up past midnight test',
|
description: 'Drag up past midnight test',
|
||||||
start,
|
start,
|
||||||
@@ -1014,10 +978,11 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
.toISOString()
|
||||||
);
|
.split('T')[0];
|
||||||
const event = thursdayCol
|
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||||
|
const event = todayCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Drag up past midnight test' });
|
.filter({ hasText: 'Drag up past midnight test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
@@ -1053,7 +1018,10 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
expect(newDurationMs).toBe(3600000);
|
expect(newDurationMs).toBe(3600000);
|
||||||
|
|
||||||
// The event should have moved to the previous day
|
// The event should have moved to the previous day
|
||||||
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
|
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1293,8 +1261,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(wednesdayAt(11));
|
const end = todayAt(11);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1303,8 +1271,26 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Resize into Thursday's column (for end resize, end must be > start)
|
// Find a column AFTER today (for end resize, end must be > start)
|
||||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let todayIndex = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
todayIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pick first column after today, or skip if today is last
|
||||||
|
for (let i = todayIndex + 1; i < headerCount; i++) {
|
||||||
|
const box = await headers.nth(i).boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
test.skip(targetX === undefined, 'No column after today to resize to');
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1332,7 +1318,7 @@ test.describe('Resize Events', () => {
|
|||||||
// First drag down vertically to engage resize (like test 3.1)
|
// First drag down vertically to engage resize (like test 3.1)
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||||||
// Then move horizontally to a later day column
|
// Then move horizontally to a later day column
|
||||||
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
|
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1354,8 +1340,8 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(wednesdayAt(14));
|
const end = todayAt(14);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1364,8 +1350,30 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Resize into Thursday's column (later day needed for end-edge resize)
|
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
|
||||||
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let foundToday = false;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
foundToday = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (foundToday) {
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If today is the last column, use the one before today instead won't work for end resize,
|
||||||
|
// so skip this test in that edge case
|
||||||
|
if (targetX === undefined) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1390,7 +1398,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to a different day column at same Y position
|
// Move to a different day column at same Y position
|
||||||
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
|
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1456,9 +1464,22 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
||||||
// Create entry spanning Wednesday evening → Thursday morning
|
// Create entry spanning today evening → tomorrow morning
|
||||||
const start = toApiTimestamp(wednesdayAt(20));
|
const start = todayAt(20);
|
||||||
const end = toApiTimestamp(thursdayAt(10));
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||||
|
const end = new Date(
|
||||||
|
tomorrow.getFullYear(),
|
||||||
|
tomorrow.getMonth(),
|
||||||
|
tomorrow.getDate(),
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi-day end resize',
|
description: 'Multi-day end resize',
|
||||||
@@ -1467,14 +1488,14 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
// Check if tomorrow column is visible
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
);
|
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find the event segment on Thursday's column
|
// Find the event segment on tomorrow's column
|
||||||
const event = thursdayCol
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi-day end resize' })
|
.filter({ hasText: 'Multi-day end resize' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1520,9 +1541,22 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry spanning Wednesday → Thursday
|
// Create entry spanning today → tomorrow
|
||||||
const start = toApiTimestamp(wednesdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(thursdayAt(14));
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||||
|
const end = new Date(
|
||||||
|
tomorrow.getFullYear(),
|
||||||
|
tomorrow.getMonth(),
|
||||||
|
tomorrow.getDate(),
|
||||||
|
14,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Backward end resize multi',
|
description: 'Backward end resize multi',
|
||||||
@@ -1531,16 +1565,29 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
const thursdayCol = page.locator(
|
// Check if tomorrow column is visible
|
||||||
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
);
|
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '12:00:00');
|
await scrollCalendarToTime(page, '12:00:00');
|
||||||
|
|
||||||
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
|
// Find today's column header to get its X center
|
||||||
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let todayX: number | undefined;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
todayX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test.skip(todayX === undefined, 'Could not find today column header');
|
||||||
|
|
||||||
// Find event segment on Thursday's column and resize end backward to Wednesday
|
// Find event segment on tomorrow's column and resize end backward to today
|
||||||
const event = thursdayCol
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Backward end resize multi' })
|
.filter({ hasText: 'Backward end resize multi' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1562,9 +1609,9 @@ test.describe('Resize Events', () => {
|
|||||||
await page.mouse.move(centerX, bottomY - 3);
|
await page.mouse.move(centerX, bottomY - 3);
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Drag down a bit first, then move to Wednesday's column at a Y after the start
|
// Drag down a bit first, then move to today's column at a Y after the start
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
||||||
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
|
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1580,8 +1627,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(thursdayAt(10));
|
const start = todayAt(10);
|
||||||
const end = toApiTimestamp(thursdayAt(14));
|
const end = todayAt(14);
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'End before start test',
|
description: 'End before start test',
|
||||||
start,
|
start,
|
||||||
@@ -1590,8 +1637,25 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Target Wednesday's column, the day before the entry
|
// Find a column BEFORE today
|
||||||
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
|
const headers = page.locator('.fc-col-header-cell');
|
||||||
|
const headerCount = await headers.count();
|
||||||
|
let targetX: number | undefined;
|
||||||
|
let todayIndex = -1;
|
||||||
|
for (let i = 0; i < headerCount; i++) {
|
||||||
|
const header = headers.nth(i);
|
||||||
|
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
||||||
|
if (isToday) {
|
||||||
|
todayIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = todayIndex - 1; i >= 0; i--) {
|
||||||
|
const box = await headers.nth(i).boundingBox();
|
||||||
|
targetX = box!.x + box!.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
test.skip(targetX === undefined, 'No column before today to test');
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1619,7 +1683,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to earlier column at a Y position near the top of the grid (before start time)
|
// Move to earlier column at a Y position near the top of the grid (before start time)
|
||||||
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
|
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
// Wait for any potential API call
|
// Wait for any potential API call
|
||||||
@@ -1668,21 +1732,30 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
// Need today and tomorrow both visible (skip Saturday with Monday week start)
|
||||||
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
|
||||||
|
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
// Use mid-day times so both start and end slots are visible in the viewport
|
// Use mid-day times so both start and end slots are visible in the viewport
|
||||||
await scrollCalendarToTime(page, '10:00:00');
|
await scrollCalendarToTime(page, '10:00:00');
|
||||||
|
|
||||||
// Find Wednesday's and Thursday's columns
|
// Find today's and tomorrow's columns
|
||||||
const wednesdayStr = toDateStr(wednesdayAt(0));
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const thursdayStr = toDateStr(thursdayAt(0));
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
|
||||||
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
|
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
await expect(wednesdayCol).toBeVisible();
|
await expect(todayCol).toBeVisible();
|
||||||
await expect(thursdayCol).toBeVisible();
|
await expect(tomorrowCol).toBeVisible();
|
||||||
|
|
||||||
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
|
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
|
||||||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||||||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
||||||
await expect(startSlot).toBeVisible();
|
await expect(startSlot).toBeVisible();
|
||||||
@@ -1690,18 +1763,18 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
|
|
||||||
const startSlotBox = await startSlot.boundingBox();
|
const startSlotBox = await startSlot.boundingBox();
|
||||||
const endSlotBox = await endSlot.boundingBox();
|
const endSlotBox = await endSlot.boundingBox();
|
||||||
const wednesdayColBox = await wednesdayCol.boundingBox();
|
const todayColBox = await todayCol.boundingBox();
|
||||||
const thursdayColBox = await thursdayCol.boundingBox();
|
const tomorrowColBox = await tomorrowCol.boundingBox();
|
||||||
|
|
||||||
// Start drag at 11:00 on Wednesday's column
|
// Start drag at 11:00 on today's column
|
||||||
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
|
const startX = todayColBox!.x + todayColBox!.width / 2;
|
||||||
const startY = startSlotBox!.y + 2;
|
const startY = startSlotBox!.y + 2;
|
||||||
|
|
||||||
// End drag at 13:00 on Thursday's column
|
// End drag at 13:00 on tomorrow's column
|
||||||
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
|
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
|
||||||
const endY = endSlotBox!.y + 2;
|
const endY = endSlotBox!.y + 2;
|
||||||
|
|
||||||
// Drag from Wednesday to Thursday, moving down first, then across
|
// Drag from today to tomorrow — move down first, then across
|
||||||
const slotHeight = await getSlotHeight(page);
|
const slotHeight = await getSlotHeight(page);
|
||||||
await page.mouse.move(startX, startY);
|
await page.mouse.move(startX, startY);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
@@ -1719,9 +1792,9 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
await expect(dialog.getByText('Start')).toBeVisible();
|
await expect(dialog.getByText('Start')).toBeVisible();
|
||||||
await expect(dialog.getByText('End')).toBeVisible();
|
await expect(dialog.getByText('End')).toBeVisible();
|
||||||
|
|
||||||
// Start date should be Wednesday, end date should be Thursday
|
// Start date should be today, end date should be tomorrow
|
||||||
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
|
await expect(dialog.getByText(todayStr)).toBeVisible();
|
||||||
await expect(dialog.getByText(thursdayStr)).toBeVisible();
|
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1803,9 +1876,17 @@ test.describe('Timezone & Localization', () => {
|
|||||||
|
|
||||||
test.describe('Multi-Day Events', () => {
|
test.describe('Multi-Day Events', () => {
|
||||||
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
||||||
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
|
// Create entry that spans from today 22:00 to tomorrow 02:00
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
const dayOfWeek = now.getDay();
|
||||||
|
// If today is Saturday (6), the entry would span to next week and may not be visible
|
||||||
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day entry',
|
description: 'Multi day entry',
|
||||||
@@ -1820,8 +1901,14 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day edit test',
|
description: 'Multi day edit test',
|
||||||
@@ -1840,8 +1927,14 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event context menu works', async ({ page, ctx }) => {
|
test('multi-day event context menu works', async ({ page, ctx }) => {
|
||||||
const start = toApiTimestamp(wednesdayAt(22));
|
const now = new Date();
|
||||||
const end = toApiTimestamp(thursdayAt(2));
|
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
||||||
|
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day ctx test',
|
description: 'Multi day ctx test',
|
||||||
@@ -1861,11 +1954,16 @@ test.describe('Multi-Day Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
const now = new Date();
|
||||||
const startDate = wednesdayAt(22);
|
const dayOfWeek = now.getDay();
|
||||||
const endDate = thursdayAt(2);
|
// Need today and tomorrow both visible (skip Saturday)
|
||||||
const start = toApiTimestamp(startDate);
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
const end = toApiTimestamp(endDate);
|
|
||||||
|
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag test',
|
description: 'Multi day drag test',
|
||||||
@@ -1875,9 +1973,12 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
|
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const eventBox = await event.boundingBox();
|
const eventBox = await event.boundingBox();
|
||||||
@@ -1912,19 +2013,27 @@ test.describe('Multi-Day Events', () => {
|
|||||||
// Duration must be preserved (4 hours)
|
// Duration must be preserved (4 hours)
|
||||||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||||||
|
|
||||||
// The start should still be on Wednesday (not jumped to Thursday)
|
// The start should still be on today (not jumped to tomorrow)
|
||||||
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
|
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
const now = new Date();
|
||||||
const startDate = wednesdayAt(22);
|
const dayOfWeek = now.getDay();
|
||||||
const endDate = thursdayAt(2);
|
// Need today and tomorrow both visible (skip Saturday)
|
||||||
const start = toApiTimestamp(startDate);
|
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
||||||
const end = toApiTimestamp(endDate);
|
|
||||||
|
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
||||||
|
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
||||||
|
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag up test',
|
description: 'Multi day drag up test',
|
||||||
@@ -1934,9 +2043,12 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on Thursday's column (00:00-02:00)
|
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
||||||
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||||
const event = thursdayCol
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
||||||
|
const event = tomorrowCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi day drag up test' });
|
.filter({ hasText: 'Multi day drag up test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
4303
package-lock.json
generated
4303
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -26,13 +26,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@inertiajs/vue3": "^2.3.23",
|
"@inertiajs/vue3": "^3.7.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^26.5.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
@@ -40,30 +40,30 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"happy-dom": "^20.8.9",
|
"happy-dom": "^20.8.9",
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
"laravel-vite-plugin": "^3.2.0",
|
||||||
"openapi-zod-client": "^1.18.3",
|
"openapi-zod-client": "^1.18.3",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^17.0.0",
|
||||||
"postcss-nesting": "^12.1.5",
|
"postcss-nesting": "^14.0.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^7.3.3",
|
"vite": "^8.3.0",
|
||||||
"vite-plugin-checker": "^0.12.0",
|
"vite-plugin-checker": "^0.12.0",
|
||||||
"vitest": "^4.1.4",
|
"vitest": "^5.0.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
"vue-tsc": "^3.2.8"
|
"vue-tsc": "^3.2.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.5",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/vue": "^1.1.11",
|
"@floating-ui/vue": "^2.0.1",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@lucide/vue": "^1.28.0",
|
"@lucide/vue": "^1.28.0",
|
||||||
"@rushstack/eslint-patch": "^1.16.1",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tanstack/vue-form": "^1.32.0",
|
"@tanstack/vue-form": "^1.32.0",
|
||||||
"@tanstack/vue-query": "^5.100.10",
|
"@tanstack/vue-query": "^5.100.10",
|
||||||
"@tanstack/vue-query-devtools": "^5.91.0",
|
"@tanstack/vue-query-devtools": "^6.1.48",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^9.2.4",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
"@tanstack/vue-virtual": "^3.13.24",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
@@ -77,10 +77,10 @@
|
|||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"focus-trap": "^8.2.0",
|
"focus-trap": "^8.2.0",
|
||||||
"parse-duration": "^2.1.6",
|
"parse-duration": "^2.1.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^4.0.3",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"tailwind-merge": "^2.6.1",
|
"tailwind-merge": "^3.7.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue-draggable-plus": "^0.6.1",
|
"vue-draggable-plus": "^0.6.1",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
|
|||||||
@@ -10,21 +10,13 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
|
|||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
| 'name'
|
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
|
||||||
| 'tasks'
|
|
||||||
| 'client_name'
|
|
||||||
| 'spent_time'
|
|
||||||
| 'progress'
|
|
||||||
| 'billable_rate'
|
|
||||||
| 'status'
|
|
||||||
| 'visibility';
|
|
||||||
export type { SortDirection } from '@/utils/useSortableTable';
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
@@ -64,16 +56,6 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { tasks } = useTasksQuery();
|
|
||||||
|
|
||||||
const taskCountMap = computed(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
tasks.value.forEach((task) => {
|
|
||||||
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
@@ -82,11 +64,6 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
|
|||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'tasks',
|
|
||||||
sortDescFirst: true,
|
|
||||||
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
@@ -184,7 +161,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gridTemplate = computed(() => {
|
const gridTemplate = computed(() => {
|
||||||
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ function handleSort(column: SortColumn) {
|
|||||||
@sort="handleSort">
|
@sort="handleSort">
|
||||||
Name
|
Name
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
|
|
||||||
Tasks
|
|
||||||
</SortableTableHeaderCell>
|
|
||||||
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
||||||
Client
|
Client
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
|
|||||||
@@ -88,19 +88,17 @@ const showEditProjectModal = ref(false);
|
|||||||
<ContextMenuTrigger as-child>
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow :href="route('projects.show', { project: project.id })">
|
<TableRow :href="route('projects.show', { project: project.id })">
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||||
<div
|
<div
|
||||||
:style="{ backgroundColor: project.color }"
|
:style="{
|
||||||
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
|
backgroundColor: project.color,
|
||||||
|
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
||||||
|
}"
|
||||||
|
class="w-3 h-3 ml-1 rounded-full"></div>
|
||||||
<span class="overflow-ellipsis overflow-hidden">
|
<span class="overflow-ellipsis overflow-hidden">
|
||||||
{{ project.name }}
|
{{ project.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
||||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
|
||||||
<span v-if="projectTasksCount">
|
|
||||||
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-text-tertiary">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"author": "solidtime",
|
"author": "solidtime",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite-plugin-dts": "^4.5.4"
|
"vite-plugin-dts": "^5.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.2",
|
"@types/chroma-js": "^3.1.2",
|
||||||
"@zodios/core": "^10.9.6",
|
"@zodios/core": "^10.9.6",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^5.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user