Compare commits

...

7 Commits

Author SHA1 Message Date
Gregor Vostrak
38db6e4edd bump invoicing extension version 2026-08-31 15:45:36 +02:00
Gregor Vostrak
9f096b1591 add helper functions for tanstack form and cent conversions 2026-08-31 15:30:39 +02:00
Gregor Vostrak
875328c32c fix type error and adapt formatting to new prettier version changes 2026-08-31 13:56:18 +02:00
Gregor Vostrak
0b307c8bc9 align alter dialog positioning with other modals 2026-08-31 13:00:22 +02:00
Gregor Vostrak
56ddd7c9f9 make sure all invoices routes show active state in the navigation 2026-08-31 13:00:22 +02:00
Gregor Vostrak
4f6a6d60d3 add combobox to ui package 2026-08-31 13:00:22 +02:00
Constantin Graf
efa3521f76 Add permissions and types for invoice recipients 2026-08-31 13:00:22 +02:00
37 changed files with 1748 additions and 1176 deletions

View File

@@ -80,6 +80,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],
@@ -147,6 +151,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],
@@ -203,6 +211,10 @@ class PermissionStore
'invoices:update', 'invoices:update',
'invoices:download', 'invoices:download',
'invoices:delete', 'invoices:delete',
'invoice-recipients:view',
'invoice-recipients:create',
'invoice-recipients:update',
'invoice-recipients:delete',
'invoice-settings:view', 'invoice-settings:view',
'invoice-settings:update', 'invoice-settings:update',
], ],

View File

@@ -9,6 +9,6 @@
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.2" "ref": "v0.0.4"
} }
} }

2193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@
"@floating-ui/core": "^1.7.5", "@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11", "@floating-ui/vue": "^1.1.11",
"@heroicons/vue": "^2.2.0", "@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.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",
@@ -67,7 +67,7 @@
"@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",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.3.0", "@vueuse/integrations": "^14.3.0",
"@zodios/core": "^10.9.6", "@zodios/core": "^10.9.6",
"chroma-js": "^3.2.0", "chroma-js": "^3.2.0",
@@ -79,7 +79,7 @@
"parse-duration": "^2.1.6", "parse-duration": "^2.1.6",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"reka-ui": "^2.9.7", "reka-ui": "^2.10.1",
"tailwind-merge": "^2.6.1", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1", "vue-draggable-plus": "^0.6.1",

View File

@@ -10,13 +10,7 @@ import Pagination from '@/Components/Common/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';
| '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';

View File

@@ -146,7 +146,10 @@ const changeSummary = computed<PlanLine[]>(() => {
{ times: range(plan.breakSlot), label: 'Break' }, { times: range(plan.breakSlot), label: 'Break' },
{ times: range(plan.secondHalf), label: workLabel }, { times: range(plan.secondHalf), label: workLabel },
...plan.shifted.map((shift) => ({ ...plan.shifted.map((shift) => ({
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift), times: moved(
req.otherEntries.find((e) => e.id === shift.id)!,
shift
),
label: props.entryLabel(shift.id), label: props.entryLabel(shift.id),
})), })),
]; ];

View File

@@ -161,7 +161,7 @@ const emit = defineEmits<{
:organization-billable-rate="organization?.billable_rate ?? null" :organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null" :no-project-value="null"
align="start" align="start"
@changed="(p, t) => emit('add-row', p, t)"> @changed="(p: string | null, t: string | null) => emit('add-row', p, t)">
<template #trigger> <template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary"> <Button variant="ghost" size="sm" class="text-text-secondary">
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" /> <PlusIcon class="h-4 w-4 mr-1 text-icon-default" />

View File

@@ -25,16 +25,21 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template> <template>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay <AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" /> class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<AlertDialogContent <div class="absolute inset-0 bg-default-background opacity-30" />
v-bind="forwarded" </AlertDialogOverlay>
:class=" <div
cn( class="fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto">
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', <AlertDialogContent
props.class v-bind="forwarded"
) :class="
"> cn(
<slot /> 'pointer-events-auto bg-default-background grid w-full max-w-lg gap-4 border border-border-tertiary p-6 shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
</AlertDialogContent> props.class
)
">
<slot />
</AlertDialogContent>
</div>
</AlertDialogPortal> </AlertDialogPortal>
</template> </template>

View File

@@ -253,7 +253,9 @@ const page = usePage<{
v-if="isInvoicingActivated() && canViewInvoices()" v-if="isInvoicingActivated() && canViewInvoices()"
title="Invoices" title="Invoices"
:icon="DocumentTextIcon" :icon="DocumentTextIcon"
:current="route().current('invoices')" :current="
route().current('invoices') || route().current('invoices.*')
"
href="/invoices"></NavigationSidebarItem> href="/invoices"></NavigationSidebarItem>
</ul> </ul>
</nav> </nav>

View File

@@ -35,8 +35,7 @@ async function deleteUser() {
} catch (error) { } catch (error) {
if (error && typeof error === 'object' && 'response' in error) { if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } } { status?: number; data?: { errors?: { password?: string[] } } } | undefined;
| undefined;
if (response?.status === 422) { if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.'; passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
} }

View File

@@ -38,8 +38,7 @@ const deleteTeam = async () => {
} catch (error) { } catch (error) {
if (error && typeof error === 'object' && 'response' in error) { if (error && typeof error === 'object' && 'response' in error) {
const response = error.response as const response = error.response as
| { status?: number; data?: { errors?: { password?: string[] } } } { status?: number; data?: { errors?: { password?: string[] } } } | undefined;
| undefined;
if (response?.status === 422) { if (response?.status === 422) {
passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.'; passwordError.value = response.data?.errors?.password?.[0] ?? 'Invalid password.';
} }

View File

@@ -118,6 +118,11 @@ export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getIn
export type DetailedInvoice = DetailedInvoiceResponse['data']; export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0]; export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type InvoiceRecipient = ZodiosResponseByAlias<
SolidTimeApi,
'getInvoiceRecipients'
>['data'][0];
export type InvoiceRecipientBody = ZodiosBodyByAlias<SolidTimeApi, 'createInvoiceRecipient'>;
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>; export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -45,14 +45,54 @@ const InvitationResource = z
const InvitationStoreRequest = z const InvitationStoreRequest = z
.object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) }) .object({ email: z.string().email(), role: z.enum(['admin', 'manager', 'employee']) })
.passthrough(); .passthrough();
const InvoiceRecipientResource = z
.object({
id: z.string(),
organization_id: z.string(),
name: z.string(),
vatin: z.union([z.string(), z.null()]),
address_line_1: z.union([z.string(), z.null()]),
address_line_2: z.union([z.string(), z.null()]),
address_line_3: z.union([z.string(), z.null()]),
address_post_code: z.union([z.string(), z.null()]),
address_city: z.union([z.string(), z.null()]),
address_country: z.union([z.string(), z.null()]),
phone: z.union([z.string(), z.null()]),
email: z.union([z.string(), z.null()]),
is_archived: z.boolean(),
archived_at: z.union([z.string(), z.null()]),
invoices_count: z.number().int(),
has_non_draft_invoices: z.boolean(),
created_at: z.union([z.string(), z.null()]),
updated_at: z.union([z.string(), z.null()]),
})
.passthrough();
const InvoiceRecipientCollection = z.array(InvoiceRecipientResource);
const InvoiceRecipientRequest = z
.object({
name: z.string(),
vatin: z.union([z.string(), z.null()]).optional(),
address_line_1: z.union([z.string(), z.null()]).optional(),
address_line_2: z.union([z.string(), z.null()]).optional(),
address_line_3: z.union([z.string(), z.null()]).optional(),
address_post_code: z.union([z.string(), z.null()]).optional(),
address_city: z.union([z.string(), z.null()]).optional(),
address_country: z.union([z.string(), z.null()]).optional(),
phone: z.union([z.string(), z.null()]).optional(),
email: z.union([z.string(), z.null()]).optional(),
is_archived: z.boolean().optional(),
})
.passthrough();
const InvoiceResource = z const InvoiceResource = z
.object({ .object({
id: z.string(), id: z.string(),
organization_id: z.string(), organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(), reference: z.string(),
seller_name: z.string(), seller_name: z.string(),
buyer_name: z.string(), recipient: z.string(),
status: z.string(), status: z.string(),
status_label: z.string(),
date: z.string(), date: z.string(),
due_at: z.string(), due_at: z.string(),
paid_date: z.string(), paid_date: z.string(),
@@ -76,16 +116,7 @@ const InvoiceStoreRequest = z
seller_address_country: z.union([z.string(), z.null()]).optional(), seller_address_country: z.union([z.string(), z.null()]).optional(),
seller_phone: z.union([z.string(), z.null()]).optional(), seller_phone: z.union([z.string(), z.null()]).optional(),
seller_email: z.union([z.string(), z.null()]).optional(), seller_email: z.union([z.string(), z.null()]).optional(),
buyer_name: z.string(), invoice_recipient_id: z.string(),
buyer_vatin: z.union([z.string(), z.null()]).optional(),
buyer_address_line_1: z.union([z.string(), z.null()]).optional(),
buyer_address_line_2: z.union([z.string(), z.null()]).optional(),
buyer_address_line_3: z.union([z.string(), z.null()]).optional(),
buyer_address_post_code: z.union([z.string(), z.null()]).optional(),
buyer_address_city: z.union([z.string(), z.null()]).optional(),
buyer_address_country: z.union([z.string(), z.null()]).optional(),
buyer_phone: z.union([z.string(), z.null()]).optional(),
buyer_email: z.union([z.string(), z.null()]).optional(),
date: z.string(), date: z.string(),
billing_period_start: z.union([z.string(), z.null()]).optional(), billing_period_start: z.union([z.string(), z.null()]).optional(),
billing_period_end: z.union([z.string(), z.null()]).optional(), billing_period_end: z.union([z.string(), z.null()]).optional(),
@@ -130,6 +161,7 @@ const DetailedInvoiceResource = z
.object({ .object({
id: z.string(), id: z.string(),
organization_id: z.string(), organization_id: z.string(),
invoice_recipient_id: z.string(),
reference: z.string(), reference: z.string(),
seller_name: z.string(), seller_name: z.string(),
seller_vatin: z.string(), seller_vatin: z.string(),
@@ -141,16 +173,7 @@ const DetailedInvoiceResource = z
seller_address_country: z.string(), seller_address_country: z.string(),
seller_phone: z.string(), seller_phone: z.string(),
seller_email: z.string(), seller_email: z.string(),
buyer_name: z.string(), recipient: InvoiceRecipientResource,
buyer_vatin: z.string(),
buyer_address_line_1: z.string(),
buyer_address_line_2: z.string(),
buyer_address_line_3: z.string(),
buyer_address_post_code: z.string(),
buyer_address_city: z.string(),
buyer_address_country: z.string(),
buyer_phone: z.string(),
buyer_email: z.string(),
paid_date: z.string(), paid_date: z.string(),
due_at: z.string(), due_at: z.string(),
discount_type: z.string(), discount_type: z.string(),
@@ -171,7 +194,7 @@ const DetailedInvoiceResource = z
entries: z.array(InvoiceEntryResource), entries: z.array(InvoiceEntryResource),
}) })
.passthrough(); .passthrough();
const InvoiceStatus = z.enum(['draft', 'sent', 'cancelled']); const InvoiceStatus = z.enum(['draft', 'sent', 'paid', 'cancelled']);
const InvoiceUpdateRequest = z const InvoiceUpdateRequest = z
.object({ .object({
status: InvoiceStatus, status: InvoiceStatus,
@@ -187,16 +210,7 @@ const InvoiceUpdateRequest = z
seller_address_country: z.union([z.string(), z.null()]), seller_address_country: z.union([z.string(), z.null()]),
seller_phone: z.union([z.string(), z.null()]), seller_phone: z.union([z.string(), z.null()]),
seller_email: z.union([z.string(), z.null()]), seller_email: z.union([z.string(), z.null()]),
buyer_name: z.string(), invoice_recipient_id: z.string(),
buyer_vatin: z.union([z.string(), z.null()]),
buyer_address_line_1: z.union([z.string(), z.null()]),
buyer_address_line_2: z.union([z.string(), z.null()]),
buyer_address_line_3: z.union([z.string(), z.null()]),
buyer_address_post_code: z.union([z.string(), z.null()]),
buyer_address_city: z.union([z.string(), z.null()]),
buyer_address_country: z.union([z.string(), z.null()]),
buyer_phone: z.union([z.string(), z.null()]),
buyer_email: z.union([z.string(), z.null()]),
date: z.string(), date: z.string(),
billing_period_start: z.union([z.string(), z.null()]), billing_period_start: z.union([z.string(), z.null()]),
billing_period_end: z.union([z.string(), z.null()]), billing_period_end: z.union([z.string(), z.null()]),
@@ -1897,6 +1911,125 @@ const endpoints = makeApi([
}, },
], ],
}, },
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'getInvoiceRecipients',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientCollection }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients',
alias: 'createInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'get',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'getInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'put',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'updateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'post',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient/duplicate',
alias: 'duplicateInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: InvoiceRecipientRequest,
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: InvoiceRecipientResource }).passthrough(),
},
{
method: 'delete',
path: '/v1/organizations/:organization/invoice-recipients/:invoiceRecipient',
alias: 'deleteInvoiceRecipient',
requestFormat: 'json',
parameters: [
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoiceRecipient',
type: 'Path',
schema: z.string(),
},
],
response: z.void(),
},
{ {
method: 'get', method: 'get',
path: '/v1/organizations/:organization/invoices', path: '/v1/organizations/:organization/invoices',
@@ -1913,6 +2046,11 @@ const endpoints = makeApi([
type: 'Query', type: 'Query',
schema: z.number().int().gte(1).lte(2147483647).optional(), schema: z.number().int().gte(1).lte(2147483647).optional(),
}, },
{
name: 'status',
type: 'Query',
schema: InvoiceStatus.optional(),
},
], ],
response: z.object({ data: InvoiceCollection }).passthrough(), response: z.object({ data: InvoiceCollection }).passthrough(),
errors: [ errors: [

View File

@@ -8,7 +8,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/packages/ui/src/tooltip'; } from '@/packages/ui/src/tooltip';
const active = defineModel({ default: false }); const active = defineModel<boolean>({ default: false });
const emit = defineEmits(['changed']); const emit = defineEmits(['changed']);
function toggleBillable() { function toggleBillable() {
active.value = !active.value; active.value = !active.value;

View File

@@ -16,7 +16,7 @@ const props = withDefaults(
); );
const emit = defineEmits(['open', 'submit']); const emit = defineEmits(['open', 'submit']);
const open = defineModel({ default: false }); const open = defineModel<boolean>({ default: false });
function handleAutofocus(event: Event) { function handleAutofocus(event: Event) {
if (props.autoFocus === false) { if (props.autoFocus === false) {

View File

@@ -13,11 +13,11 @@ import { type ComputedRef } from 'vue';
const temporaryCustomTimerEntry = ref<string>(''); const temporaryCustomTimerEntry = ref<string>('');
const start = defineModel('start', { const start = defineModel<string>('start', {
default: '', default: '',
}); });
const end = defineModel('end', { const end = defineModel<string>('end', {
default: '', default: '',
}); });

View File

@@ -20,7 +20,7 @@ const NONE_ID = 'none';
const ROW_HEIGHT = 32; const ROW_HEIGHT = 32;
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const props = defineProps<{ const props = defineProps<{

View File

@@ -32,7 +32,7 @@ const props = withDefaults(
); );
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const open = ref(false); const open = ref(false);

View File

@@ -21,7 +21,7 @@ import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries'; import type { TimeEntriesGroupedByType } from '@/types/time-entries';
const selectedTimeEntries = defineModel<TimeEntry[]>('selected', { const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [], default: () => [],
}); });
const props = withDefaults( const props = withDefaults(

View File

@@ -20,7 +20,7 @@ const emit = defineEmits<{
}>(); }>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const timeEntryTags = computed<Tag[]>(() => { const timeEntryTags = computed<Tag[]>(() => {

View File

@@ -10,7 +10,7 @@ const emit = defineEmits<{
}>(); }>();
const model = defineModel<string[]>({ const model = defineModel<string[]>({
default: [], default: () => [],
}); });
const iconColorClasses = computed(() => { const iconColorClasses = computed(() => {
if (model.value.length > 0) { if (model.value.length > 0) {

View File

@@ -7,7 +7,7 @@ import { getUserTimezone } from './utils/settings';
import { getDayJsInstance } from './utils/time'; import { getDayJsInstance } from './utils/time';
import { useSessionStorage } from '@vueuse/core'; import { useSessionStorage } from '@vueuse/core';
const show = defineModel('show', { default: false }); const show = defineModel<boolean>('show', { default: false });
const emit = defineEmits<{ const emit = defineEmits<{
update: [timezone: string]; update: [timezone: string];

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ComboboxRootEmits, ComboboxRootProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxRoot, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxRootProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxRootEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<!-- Keep the trigger inside this root. Reka treats anything within the root
element as "not outside", so it suppresses its own dismissal for trigger
clicks and ComboboxInput's blur-close ignores them. Putting the trigger
in a separate Popover instead gives two competing open states, and the
popover then closes on mousedown and reopens on the following click. -->
<ComboboxRoot v-slot="slotProps" v-bind="forwarded" :class="cn('min-w-0', props.class)">
<slot v-bind="slotProps" />
</ComboboxRoot>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxAnchorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxAnchor, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxAnchorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxAnchor v-bind="forwarded" :class="cn('w-full', props.class)">
<slot />
</ComboboxAnchor>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ComboboxInputEmits, ComboboxInputProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { Search } from '@lucide/vue';
import { ComboboxInput, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<ComboboxInputProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxInputEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<div class="relative items-center border-b border-card-background-separator">
<ComboboxInput
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'h-10 w-full border-0 rounded-none bg-transparent pl-9 pr-3 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-0',
props.class
)
" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-3">
<Search class="size-4 text-text-tertiary" />
</span>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ComboboxItemEmits, ComboboxItemProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxItem, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxItemProps & { class?: HTMLAttributes['class'] }>();
const emits = defineEmits<ComboboxItemEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxItem
v-bind="forwarded"
:class="
cn(
'flex w-full cursor-default items-center rounded-md px-2 py-1.5 text-sm text-text-primary data-[highlighted]:bg-card-background-active',
props.class
)
">
<slot />
</ComboboxItem>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { ComboboxContentEmits, ComboboxContentProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxContent, ComboboxPortal, useForwardPropsEmits } from 'reka-ui';
import { cn } from '../utils/cn';
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<ComboboxContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper',
align: 'start',
sideOffset: 4,
class: undefined,
}
);
const emits = defineEmits<ComboboxContentEmits>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<ComboboxPortal>
<ComboboxContent
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'z-50 w-[--reka-popper-anchor-width] min-w-60 overflow-hidden rounded-lg border border-popover-border bg-popover text-popover-foreground shadow-dropdown outline-none origin-[var(--reka-combobox-content-transform-origin)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
">
<slot />
</ComboboxContent>
</ComboboxPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { ComboboxSeparatorProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxSeparator, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxSeparatorProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxSeparator
v-bind="forwarded"
:class="cn('my-1 h-px bg-card-background-separator', props.class)" />
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ComboboxTriggerProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxTrigger, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxTriggerProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<!-- Reka hardcodes tabindex="-1" here because it expects a ComboboxInput
next to the trigger in the anchor to be the focusable control. Our input
lives inside ComboboxList, so this trigger is the control and has to be
tabbable. tabindex is a fallthrough attr, which Vue applies after Reka's
own props and therefore wins.
Reka also hardcodes aria-label="Show popup", which would otherwise be
the accessible name. Pass :aria-label on the child element (it wins
again, because Slot merges child props over attrs) to name the trigger
after its current value. -->
<ComboboxTrigger v-bind="forwarded" :class="cn(props.class)" tabindex="0">
<slot />
</ComboboxTrigger>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ComboboxViewportProps } from 'reka-ui';
import type { HTMLAttributes } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { ComboboxViewport, useForwardProps } from 'reka-ui';
import { cn } from '../utils/cn';
const props = defineProps<ComboboxViewportProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<ComboboxViewport
v-bind="forwarded"
:class="cn('ui-combobox-viewport max-h-60 overflow-y-auto p-2', props.class)">
<slot />
</ComboboxViewport>
</template>
<style>
/* Reka's ComboboxViewport injects a global style that hides scrollbars; the
attribute+class selector below is more specific and restores them. */
[data-reka-combobox-viewport].ui-combobox-viewport {
scrollbar-width: thin;
-ms-overflow-style: auto;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar {
display: block;
width: 8px;
}
[data-reka-combobox-viewport].ui-combobox-viewport::-webkit-scrollbar-thumb {
background-color: rgb(127 127 127 / 0.4);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,9 @@
export { default as Combobox } from './Combobox.vue';
export { default as ComboboxAnchor } from './ComboboxAnchor.vue';
export { default as ComboboxInput } from './ComboboxInput.vue';
export { default as ComboboxItem } from './ComboboxItem.vue';
export { default as ComboboxList } from './ComboboxList.vue';
export { default as ComboboxSeparator } from './ComboboxSeparator.vue';
export { default as ComboboxTrigger } from './ComboboxTrigger.vue';
export { default as ComboboxViewport } from './ComboboxViewport.vue';
export { ComboboxVirtualizer } from 'reka-ui';

View File

@@ -57,6 +57,16 @@ import {
CalendarNextButton, CalendarNextButton,
CalendarPrevButton, CalendarPrevButton,
} from './calendar/index'; } from './calendar/index';
import {
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
} from './combobox/index';
import { CommandPalette } from './CommandPalette/index'; import { CommandPalette } from './CommandPalette/index';
import { import {
ContextMenu, ContextMenu,
@@ -176,6 +186,14 @@ export {
CardTitle, CardTitle,
Checkbox, Checkbox,
color, color,
Combobox,
ComboboxAnchor,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
CommandPalette, CommandPalette,
ContextMenu, ContextMenu,
ContextMenuCheckboxItem, ContextMenuCheckboxItem,

View File

@@ -43,6 +43,13 @@ export function formatCents(
return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat); return formatMoney(amount / 100, currency, format, currencySymbol, numberFormat);
} }
/*
* Converts a major-unit amount (e.g. 19.99) to integer hundredths (i.e. cents)
*/
export function toCents(value: number | undefined | null): number {
return Math.round((value || 0) * 100);
}
export function getOrganizationCurrencySymbol(currency: string) { export function getOrganizationCurrencySymbol(currency: string) {
return (0) return (0)
.toLocaleString('de-DE', { .toLocaleString('de-DE', {

View File

@@ -1,9 +1,5 @@
export type NumberFormat = export type NumberFormat =
| 'point-comma' 'point-comma' | 'comma-point' | 'space-comma' | 'space-point' | 'apostrophe-point';
| 'comma-point'
| 'space-comma'
| 'space-point'
| 'apostrophe-point';
/** /**
* Formats a number according to the specified format * Formats a number according to the specified format

View File

@@ -23,6 +23,21 @@ export function getApiValidationFieldErrors(error: unknown): Record<string, stri
return fieldErrors; return fieldErrors;
} }
/*
* Like getApiValidationFieldErrors, but with Laravel's dot-notation array
* indices (entries.0.unit_price) converted to the bracket notation of
* TanStack Form field names (entries[0].unit_price), so that the errors
* attach to the mounted fields.
*/
export function getApiValidationFormFieldErrors(error: unknown): Record<string, string> {
return Object.fromEntries(
Object.entries(getApiValidationFieldErrors(error)).map(([field, message]) => [
field.replace(/\.(\d+)(?=\.|$)/g, '[$1]'),
message,
])
);
}
export function getApiValidationMessage(error: unknown, fallback: string): string { export function getApiValidationMessage(error: unknown, fallback: string): string {
if (!isApiValidationError(error)) { if (!isApiValidationError(error)) {
return fallback; return fallback;

View File

@@ -27,13 +27,7 @@ import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import type { Organization } from '@/types/models'; import type { Organization } from '@/types/models';
export type CommandGroup = export type CommandGroup =
| 'timer' 'timer' | 'active-timer' | 'navigation' | 'create' | 'theme' | 'organization' | 'entity';
| 'active-timer'
| 'navigation'
| 'create'
| 'theme'
| 'organization'
| 'entity';
export interface Command { export interface Command {
id: string; id: string;

View File

@@ -385,21 +385,19 @@ export function useCommandPalette() {
const matching = projects.value const matching = projects.value
.filter((p: Project) => p.name.toLowerCase().includes(query)) .filter((p: Project) => p.name.toLowerCase().includes(query))
.slice(0, maxPerType) .slice(0, maxPerType)
.map( .map((p: Project): EntitySearchResult => ({
(p: Project): EntitySearchResult => ({ id: `entity-project-${p.id}`,
id: `entity-project-${p.id}`, label: p.name,
label: p.name, icon: ENTITY_ICONS.project,
icon: ENTITY_ICONS.project, keywords: ['project'],
keywords: ['project'], action: () => {
action: () => { closePaletteAfterAction();
closePaletteAfterAction(); router.visit(route('projects.show', { project: p.id }));
router.visit(route('projects.show', { project: p.id })); },
}, entityType: 'project',
entityType: 'project', color: p.color,
color: p.color, badgeClass: ENTITY_BADGE_CLASSES.project,
badgeClass: ENTITY_BADGE_CLASSES.project, }));
})
);
results.push(...matching); results.push(...matching);
} }
@@ -407,20 +405,18 @@ export function useCommandPalette() {
const matching = clients.value const matching = clients.value
.filter((c: Client) => c.name.toLowerCase().includes(query)) .filter((c: Client) => c.name.toLowerCase().includes(query))
.slice(0, maxPerType) .slice(0, maxPerType)
.map( .map((c: Client): EntitySearchResult => ({
(c: Client): EntitySearchResult => ({ id: `entity-client-${c.id}`,
id: `entity-client-${c.id}`, label: c.name,
label: c.name, icon: ENTITY_ICONS.client,
icon: ENTITY_ICONS.client, keywords: ['client'],
keywords: ['client'], action: () => {
action: () => { closePaletteAfterAction();
closePaletteAfterAction(); router.visit(route('clients'));
router.visit(route('clients')); },
}, entityType: 'client',
entityType: 'client', badgeClass: ENTITY_BADGE_CLASSES.client,
badgeClass: ENTITY_BADGE_CLASSES.client, }));
})
);
results.push(...matching); results.push(...matching);
} }
@@ -428,22 +424,20 @@ export function useCommandPalette() {
const matching = tasks.value const matching = tasks.value
.filter((t: Task) => t.name.toLowerCase().includes(query)) .filter((t: Task) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType) .slice(0, maxPerType)
.map( .map((t: Task): EntitySearchResult => ({
(t: Task): EntitySearchResult => ({ id: `entity-task-${t.id}`,
id: `entity-task-${t.id}`, label: t.name,
label: t.name, icon: ENTITY_ICONS.task,
icon: ENTITY_ICONS.task, keywords: ['task'],
keywords: ['task'], action: () => {
action: () => { closePaletteAfterAction();
closePaletteAfterAction(); if (t.project_id) {
if (t.project_id) { router.visit(route('projects.show', { project: t.project_id }));
router.visit(route('projects.show', { project: t.project_id })); }
} },
}, entityType: 'task',
entityType: 'task', badgeClass: ENTITY_BADGE_CLASSES.task,
badgeClass: ENTITY_BADGE_CLASSES.task, }));
})
);
results.push(...matching); results.push(...matching);
} }
@@ -451,20 +445,18 @@ export function useCommandPalette() {
const matching = tags.value const matching = tags.value
.filter((t: Tag) => t.name.toLowerCase().includes(query)) .filter((t: Tag) => t.name.toLowerCase().includes(query))
.slice(0, maxPerType) .slice(0, maxPerType)
.map( .map((t: Tag): EntitySearchResult => ({
(t: Tag): EntitySearchResult => ({ id: `entity-tag-${t.id}`,
id: `entity-tag-${t.id}`, label: t.name,
label: t.name, icon: ENTITY_ICONS.tag,
icon: ENTITY_ICONS.tag, keywords: ['tag'],
keywords: ['tag'], action: () => {
action: () => { closePaletteAfterAction();
closePaletteAfterAction(); router.visit(route('tags'));
router.visit(route('tags')); },
}, entityType: 'tag',
entityType: 'tag', badgeClass: ENTITY_BADGE_CLASSES.tag,
badgeClass: ENTITY_BADGE_CLASSES.tag, }));
})
);
results.push(...matching); results.push(...matching);
} }
@@ -472,20 +464,18 @@ export function useCommandPalette() {
const matching = members.value const matching = members.value
.filter((m: Member) => m.name.toLowerCase().includes(query)) .filter((m: Member) => m.name.toLowerCase().includes(query))
.slice(0, maxPerType) .slice(0, maxPerType)
.map( .map((m: Member): EntitySearchResult => ({
(m: Member): EntitySearchResult => ({ id: `entity-member-${m.id}`,
id: `entity-member-${m.id}`, label: m.name,
label: m.name, icon: ENTITY_ICONS.member,
icon: ENTITY_ICONS.member, keywords: ['member'],
keywords: ['member'], action: () => {
action: () => { closePaletteAfterAction();
closePaletteAfterAction(); router.visit(route('members'));
router.visit(route('members')); },
}, entityType: 'member',
entityType: 'member', badgeClass: ENTITY_BADGE_CLASSES.member,
badgeClass: ENTITY_BADGE_CLASSES.member, }));
})
);
results.push(...matching); results.push(...matching);
} }