Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14,442 changes: 7,888 additions & 6,554 deletions dist/init.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/style.css

Large diffs are not rendered by default.

73 changes: 72 additions & 1 deletion resources/js/api/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ import type { Paginated, Wrapped } from '@/types/api'
import type { BoardColumn, BoardParams } from '@/types/board'
import type { Project } from '@/types/project'
import type { ProjectMember, ProjectMemberInput } from '@/types/project-member'
import type { Task, TaskInput, TaskListParams, TaskMoveInput } from '@/types/task'
import type {
Task,
TaskBulkInput,
TaskBulkResult,
TaskInput,
TaskListParams,
TaskMoveInput,
} from '@/types/task'
import type { TaskStatus } from '@/types/task-status'
import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry'

Expand All @@ -20,6 +27,10 @@ export const BOARD_API = {
tasks: `${BASE}/tasks`,
task: (id: number): string => `${BASE}/tasks/${id}`,
moveTask: (id: number): string => `${BASE}/tasks/${id}/move`,
startTask: (id: number): string => `${BASE}/tasks/${id}/start`,
stopTask: (id: number): string => `${BASE}/tasks/${id}/stop`,
taskTimeLog: (id: number): string => `${BASE}/tasks/${id}/time-log`,
bulkTasks: `${BASE}/tasks/bulk`,
taskStatuses: `${BASE}/task-statuses`,
timeEntries: `${BASE}/time-entries`,
projectMembers: (projectId: number): string => `${BASE}/projects/${projectId}/members`,
Expand Down Expand Up @@ -73,6 +84,66 @@ export async function deleteTask(client: AxiosInstance, id: number): Promise<voi
await client.delete(BOARD_API.task(id))
}

/** One task with the time summary the list carries, for the task page. */
export async function fetchTaskDetail(client: AxiosInstance, id: number): Promise<Task> {
const { data } = await client.get<Wrapped<Task>>(BOARD_API.task(id))

return data.data
}

/**
* Put the caller's clock on a task.
*
* Answers 409 `timer_already_running` when their timer is on another task,
* which is a question for the caller rather than a failure: the run control
* offers to stop the other one first.
*/
export async function startTask(
client: AxiosInstance,
id: number,
description: string | null = null,
): Promise<TimeEntry> {
const body = description === null ? {} : { description }
const { data } = await client.post<Wrapped<TimeEntry>>(BOARD_API.startTask(id), body)

return data.data
}

/** Close the caller's running entry on a task. 409 `timer_mismatch` if it moved. */
export async function stopTask(client: AxiosInstance, id: number): Promise<TimeEntry> {
const { data } = await client.post<Wrapped<TimeEntry>>(BOARD_API.stopTask(id))

return data.data
}

/**
* Every entry logged against one task, running first and then newest.
*
* A caller who may not see other members' time gets their own rows, so the
* grid renders either way and never has to ask which case it is in.
*/
export async function fetchTaskTimeLog(client: AxiosInstance, id: number): Promise<TimeEntry[]> {
const { data } = await client.get<Wrapped<TimeEntry[]>>(BOARD_API.taskTimeLog(id))

return data.data ?? []
}

/**
* Apply one action to a selection of tasks.
*
* The endpoint is partial by design: it reports how many it changed and names
* the ones it refused, so a locked task in the selection does not sink the
* rest of it.
*/
export async function bulkTasks(
client: AxiosInstance,
input: TaskBulkInput,
): Promise<TaskBulkResult> {
const { data } = await client.post<TaskBulkResult>(BOARD_API.bulkTasks, input)

return { updated: data?.updated ?? 0, failed: data?.failed ?? [] }
}

/**
* Drop a task between two neighbours of a column.
*
Expand Down
59 changes: 15 additions & 44 deletions resources/js/components/AllTimeTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { formatDate, toDateString } from '@/support/format'
import { useTranslate } from '@/support/i18n'
import { formatDuration, localDateOf } from '@/support/time'
import type { CompanyMember } from '@/types/member'
import type { Project } from '@/types/project'
import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry'

type NotifyType = 'success' | 'error' | 'warning' | 'info'
Expand All @@ -31,7 +30,15 @@ const props = defineProps<{
client: AxiosInstance
notify: (type: NotifyType, message: string) => void
members: CompanyMember[]
projects: Project[]
/**
* Who and what the screen above is filtered to.
*
* The member and project pickers live on the Tasks header now, shared by
* every view, so the table follows them rather than offering a second pair
* that could disagree with the first.
*/
memberId: number | null
projectId: number | null
/** Bumped by the page whenever an entry was saved elsewhere. */
reloadToken: number
}>()
Expand All @@ -46,49 +53,21 @@ const t = useTranslate()
const tableRef = ref<{ refresh: (preservePage?: boolean) => void } | null>(null)

const filters = reactive<{
memberId: number | null
projectId: number | null
from: string
to: string
billing: BillingFilter
}>({
memberId: null,
projectId: null,
from: '',
to: '',
billing: 'ALL',
})

const memberOptions = computed<Option[]>(() => [
{ id: 0, label: t('tasks_projects.time.filters.any_member') },
...props.members.map((member) => ({ id: member.id, label: member.name })),
])

const projectOptions = computed<Option[]>(() => [
{ id: 0, label: t('tasks_projects.time.filters.any_project') },
...props.projects.map((project) => ({ id: project.id, label: project.name })),
])

const billingOptions = computed<Option[]>(() => [
{ id: 'ALL', label: t('tasks_projects.time.filters.all') },
{ id: 'BILLED', label: t('tasks_projects.time.billed') },
{ id: 'UNBILLED', label: t('tasks_projects.time.unbilled') },
])

const memberOption = computed<Option>({
get: () => optionFor(memberOptions.value, filters.memberId ?? 0),
set: (option: Option) => {
filters.memberId = typeof option.id === 'number' && option.id > 0 ? option.id : null
},
})

const projectOption = computed<Option>({
get: () => optionFor(projectOptions.value, filters.projectId ?? 0),
set: (option: Option) => {
filters.projectId = typeof option.id === 'number' && option.id > 0 ? option.id : null
},
})

const billingOption = computed<Option>({
get: () => optionFor(billingOptions.value, filters.billing),
set: (option: Option) => {
Expand Down Expand Up @@ -119,6 +98,8 @@ const columns = computed(() => [

watch(filters, () => refresh())

watch([() => props.memberId, () => props.projectId], () => refresh())

watch(() => props.reloadToken, () => refresh(true))

function optionFor(options: Option[], id: number | BillingFilter): Option {
Expand All @@ -130,8 +111,6 @@ function refresh(preservePage = false): void {
}

function clearFilters(): void {
filters.memberId = null
filters.projectId = null
filters.from = ''
filters.to = ''
filters.billing = 'ALL'
Expand Down Expand Up @@ -166,12 +145,12 @@ async function fetchEntries({ page }: { page: number }): Promise<{
}> {
const params: TimeEntryListParams = { page, limit: TIME_PAGE_SIZE }

if (filters.memberId !== null) {
params.user_id = filters.memberId
if (props.memberId !== null) {
params.user_id = props.memberId
}

if (filters.projectId !== null) {
params.project_id = filters.projectId
if (props.projectId !== null) {
params.project_id = props.projectId
}

if (filters.from !== '') {
Expand Down Expand Up @@ -220,14 +199,6 @@ function paginationOf(
<template>
<section>
<BaseFilterWrapper show row-on-xl class="mt-3" @clear="clearFilters">
<BaseInputGroup :label="t('tasks_projects.time.filters.member')" class="mt-2 flex-1">
<BaseSelectInput v-model="memberOption" :options="memberOptions" label-key="label" />
</BaseInputGroup>

<BaseInputGroup :label="t('tasks_projects.time.filters.project')" class="mt-2 flex-1">
<BaseSelectInput v-model="projectOption" :options="projectOptions" label-key="label" />
</BaseInputGroup>

<BaseInputGroup :label="t('tasks_projects.time.filters.from')" class="mt-2 flex-1">
<BaseDatePicker :model-value="filters.from" @update:model-value="onFrom" />
</BaseInputGroup>
Expand Down
96 changes: 96 additions & 0 deletions resources/js/components/BulkActionBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useTranslate } from '@/support/i18n'
import type { SelectOption } from '@/types/board'
import type { TaskStatus } from '@/types/task-status'

const props = defineProps<{
/** How many tasks the selection holds. The bar hides at zero. */
count: number
statuses: TaskStatus[]
/** True while a bulk request is in flight. */
busy: boolean
}>()

const emit = defineEmits<{
(event: 'status', statusId: number): void
(event: 'delete'): void
(event: 'clear'): void
(event: 'select-page'): void
}>()

const t = useTranslate()

const status = ref<SelectOption | null>(null)

const statusOptions = computed<SelectOption[]>(() =>
props.statuses.map((record) => ({ id: record.id, label: record.name })),
)

// The picker is an action, not a setting: it forgets what was chosen so the
// next selection starts from "move to" rather than from the last column used.
watch(status, (option) => {
if (option !== null) {
emit('status', option.id)
status.value = null
}
})
</script>

<template>
<div
v-if="count > 0"
class="mt-3 flex flex-wrap items-center gap-3 rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5"
>
<span class="text-sm font-medium text-primary-700">
{{ t('tasks_projects.tasks.bulk.selected', { count }) }}
</span>

<div class="min-w-48">
<BaseSelectInput
v-model="status"
:options="statusOptions"
:disabled="busy"
:placeholder="t('tasks_projects.tasks.bulk.change_status')"
label-key="label"
/>
</div>

<BaseButton variant="primary-outline" size="sm" :disabled="busy" @click="emit('delete')">
<template #left="slotProps">
<BaseIcon name="TrashIcon" :class="slotProps.class" />
</template>
{{ t('tasks_projects.tasks.bulk.delete') }}
</BaseButton>

<!-- Invoicing arrives in its own slice; the affordance is here so the bar
does not move under people once it does. -->
<span
:title="t('tasks_projects.tasks.invoice_soon')"
class="inline-flex cursor-not-allowed opacity-60"
>
<BaseButton variant="primary-outline" size="sm" disabled>
<template #left="slotProps">
<BaseIcon name="BanknotesIcon" :class="slotProps.class" />
</template>
{{ t('tasks_projects.tasks.bulk.invoice') }}
</BaseButton>
</span>

<button
type="button"
class="ml-auto text-sm font-medium text-primary-600 hover:underline"
@click="emit('select-page')"
>
{{ t('tasks_projects.tasks.bulk.select_page') }}
</button>

<button
type="button"
class="text-sm font-medium text-primary-600 hover:underline"
@click="emit('clear')"
>
{{ t('tasks_projects.tasks.bulk.clear') }}
</button>
</div>
</template>
46 changes: 46 additions & 0 deletions resources/js/components/InvoicedBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useTranslate } from '@/support/i18n'
import type { TaskInvoiceState } from '@/types/task'

const props = withDefaults(
defineProps<{
/** What the task's time summary says about its billable time. */
state?: TaskInvoiceState
}>(),
{ state: 'none' },
)

const t = useTranslate()

/**
* A task with nothing billable to say shows nothing.
*
* The badge marks a state worth acting on: money already invoiced, or money
* waiting to be. A task nobody has logged billable time against is neither,
* and a badge reading "none" would only add noise to every row.
*/
const visible = computed<boolean>(() => props.state === 'invoiced' || props.state === 'uninvoiced')

const label = computed<string>(() =>
props.state === 'invoiced'
? t('tasks_projects.tasks.invoiced')
: t('tasks_projects.tasks.uninvoiced'),
)

/**
* The host badge carries its own colour classes and its stylesheet is loaded
* after the module's, so the override has to be important to hold.
*/
const tone = computed<string>(() =>
props.state === 'invoiced'
? 'bg-alert-success-bg! text-alert-success-text!'
: 'bg-alert-warning-bg! text-alert-warning-text!',
)
</script>

<template>
<BaseBadge v-if="visible" class="rounded-full whitespace-nowrap" :class="tone">
{{ label }}
</BaseBadge>
</template>
4 changes: 2 additions & 2 deletions resources/js/components/QuickStartOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const props = defineProps<{
}>()

const emit = defineEmits<{
(event: 'open-timesheet'): void
(event: 'open-week'): void
}>()

const SEARCH_DEBOUNCE_MS = 300
Expand Down Expand Up @@ -230,7 +230,7 @@ async function discard(): Promise<void> {
<button
type="button"
class="text-xs text-primary-500 hover:underline"
@click="emit('open-timesheet')"
@click="emit('open-week')"
>
{{ t('tasks_projects.timer.open_timesheet') }}
</button>
Expand Down
Loading
Loading