mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
18 Commits
feature/e2
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5aff20fc | ||
|
|
9e5aa77e41 | ||
|
|
0791a68283 | ||
|
|
e66679274d | ||
|
|
717fd35d76 | ||
|
|
5a3a5995cc | ||
|
|
a8e6d28eab | ||
|
|
9c9aeeab0f | ||
|
|
8a1253e101 | ||
|
|
661fa25da1 | ||
|
|
d77048a7dd | ||
|
|
4676af9b40 | ||
|
|
18c8e62228 | ||
|
|
e7703aef64 | ||
|
|
86d0497000 | ||
|
|
522f7d2bd2 | ||
|
|
2f807e4808 | ||
|
|
93d9db349b |
@@ -1,240 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
:root.dark {
|
||||
--color-bg-primary: #101012;
|
||||
--color-bg-secondary: #17181B;
|
||||
--color-bg-tertiary: #2A2C32;
|
||||
--color-bg-quaternary: #141518;
|
||||
--color-bg-background: #090909;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e3e4e6;
|
||||
--color-text-tertiary: #969799;
|
||||
--color-text-quaternary: #595a5c;
|
||||
/* Import shared solidtime styles from UI package */
|
||||
@import '../js/packages/ui/styles.css';
|
||||
|
||||
--color-border-primary: #191b1f;
|
||||
--color-border-secondary: #23252a;
|
||||
--color-border-tertiary: #2c2e33;
|
||||
--color-border-quaternary: #393B42;
|
||||
--color-input-border-active: rgba(255,255,255,0.3);
|
||||
|
||||
--theme-color-chart: var(--color-accent-200);
|
||||
|
||||
--theme-color-menu-active: var(--color-bg-secondary);
|
||||
--theme-color-card-background: var(--color-bg-secondary);
|
||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-row-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||
--theme-color-icon-default: var(--color-text-tertiary);
|
||||
|
||||
--theme-color-ring: rgba(255,255,255,0.5);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-text: var(--color-text-primary);
|
||||
|
||||
--theme-color-input-background: var(--color-bg-secondary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #f7f7f8;
|
||||
--color-bg-tertiary: #eeeeef;
|
||||
--color-bg-quaternary: #e1e1e3;
|
||||
--color-bg-background: #F5F5F5;
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #3f3f46;
|
||||
--color-text-tertiary: #57575C;
|
||||
--color-text-quaternary: #a1a1aa;
|
||||
--color-border-primary: #e7e7e7;
|
||||
--color-border-secondary: #e5e5e5;
|
||||
--color-border-tertiary: #dfdfdf;
|
||||
--color-border-quaternary: #d1d1d1;
|
||||
--color-input-border-active: rgba(0,0,0,0.3);
|
||||
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||
|
||||
--theme-color-card-background: var(--color-bg-primary);
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-chart: var(--color-accent-400);
|
||||
|
||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
--theme-color-row-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-background: var(--color-bg-secondary);
|
||||
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||
--theme-color-icon-default: var(--color-text-quaternary);
|
||||
|
||||
--theme-color-ring: rgba(0,0,0, 0.7);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-text: #FFFFFF;
|
||||
|
||||
--theme-color-input-background: var(--color-bg-primary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||
|
||||
--color-accent-default: rgb(var(--color-accent-100));
|
||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||
--theme-color-default-background: #FCFCFC;
|
||||
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||
--theme-color-card-border: var(--color-border-secondary);
|
||||
--theme-color-card-border-active: var(--color-border-tertiary);
|
||||
--theme-color-default-background-separator: var(--color-border-primary);
|
||||
--theme-color-primary-text: var(--color-text-primary);
|
||||
--theme-color-input-border: var(--color-border-quaternary);
|
||||
--theme-color-tab-background: var(--theme-color-card-background);
|
||||
--theme-color-tab-background-active: var(--theme-color-card-background-active);
|
||||
--theme-color-tab-border: var(--theme-color-card-border);
|
||||
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
|
||||
--theme-color-row-border: var(--theme-color-card-border);
|
||||
|
||||
--color-accent-50: 240, 249, 255; /* sky-50 */
|
||||
--color-accent-100: 224, 242, 254; /* sky-100 */
|
||||
--color-accent-200: 186, 230, 253; /* sky-200 */
|
||||
--color-accent-300: 125, 211, 252; /* sky-300 */
|
||||
--color-accent-400: 56, 189, 248; /* sky-400 */
|
||||
--color-accent-500: 14, 165, 233; /* sky-500 */
|
||||
--color-accent-600: 2, 132, 199; /* sky-600 */
|
||||
--color-accent-700: 3, 105, 161; /* sky-700 */
|
||||
--color-accent-800: 7, 89, 133; /* sky-800 */
|
||||
--color-accent-900: 12, 74, 110; /* sky-900 */
|
||||
--color-accent-950: 8, 47, 73; /* sky-950 */
|
||||
|
||||
--theme-button-secondary-background: var(--theme-color-card-background);
|
||||
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
|
||||
--popover-border: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track, ::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
body {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
|
||||
/* Inter Variable Font with browser compatibility considerations */
|
||||
/* Main app specific styles - Inter font */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
src:
|
||||
url('/fonts/InterVariable.woff2') format('woff2'),
|
||||
url('/fonts/InterVariable.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
--chart-3: var(--color-accent-600);
|
||||
--chart-4: var(--color-accent-700);
|
||||
--chart-5: var(--color-accent-800);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--theme-color-button-primary-background);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
--chart-3: var(--color-accent-400);
|
||||
--chart-4: var(--color-accent-500);
|
||||
--chart-5: var(--color-accent-600);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
|
||||
const props = defineProps<{
|
||||
icon: Component;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Switch } from '@/Components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
||||
@@ -158,6 +158,7 @@ async function discardCurrentTimeEntry() {
|
||||
}
|
||||
|
||||
const { tags } = storeToRefs(useTagsStore());
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -168,6 +169,8 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
:create-client="createClient"
|
||||
:create-tag="createTag"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:projects
|
||||
:tasks
|
||||
:tags
|
||||
@@ -194,6 +197,7 @@ const { tags } = storeToRefs(useTagsStore());
|
||||
:tags
|
||||
:tasks
|
||||
:projects
|
||||
:time-entries
|
||||
:create-tag
|
||||
:is-active
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { buttonVariants } from '@/packages/ui/src';
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { Calendar } from '@/Components/ui/calendar';
|
||||
import { CalendarIcon, XIcon } from 'lucide-vue-next';
|
||||
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src/index';
|
||||
import { ChevronRight } from 'lucide-vue-next';
|
||||
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { ChevronLeft } from 'lucide-vue-next';
|
||||
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import {
|
||||
RangeCalendarCellTrigger,
|
||||
type RangeCalendarCellTriggerProps,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { ChevronRight } from 'lucide-vue-next';
|
||||
import { RangeCalendarNext, type RangeCalendarNextProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/Components/ui/button';
|
||||
import { cn, buttonVariants } from '@/packages/ui/src';
|
||||
import { ChevronLeft } from 'lucide-vue-next';
|
||||
import { RangeCalendarPrev, type RangeCalendarPrevProps, useForwardProps } from 'reka-ui';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
@@ -47,7 +47,7 @@ import { api } from '@/packages/api/src';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import Button from '@/Components/ui/button/Button.vue';
|
||||
import { Button } from '@/packages/ui/src';
|
||||
import { openFeedback } from '@/utils/feedback';
|
||||
|
||||
defineProps({
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useClientsStore } from '@/utils/useClients';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTasksStore } from '@/utils/useTasks';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
|
||||
const calendarStart = ref<Date | undefined>(undefined);
|
||||
const calendarEnd = ref<Date | undefined>(undefined);
|
||||
@@ -129,6 +131,8 @@ function onRefresh() {
|
||||
:tags="tags"
|
||||
:loading="timeEntriesLoading"
|
||||
:enable-estimated-time="isAllowedToPerformPremiumAction()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProjects()"
|
||||
:create-time-entry="createTimeEntry"
|
||||
:update-time-entry="updateTimeEntry"
|
||||
:delete-time-entry="deleteTimeEntry"
|
||||
|
||||
4
resources/js/packages/api/package-lock.json
generated
4
resources/js/packages/api/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.5",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@zodios/core": "^10.9.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/api",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "Package containing the solidtime api client and type declarations",
|
||||
"main": "./dist/solidtime-api.umd.cjs",
|
||||
"module": "./dist/solidtime-api.js",
|
||||
|
||||
1523
resources/js/packages/ui/package-lock.json
generated
1523
resources/js/packages/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.13",
|
||||
"description": "Package containing the solidtime ui components",
|
||||
"main": "./dist/solidtime-ui-lib.umd.cjs",
|
||||
"module": "./dist/solidtime-ui-lib.js",
|
||||
@@ -21,11 +21,14 @@
|
||||
"default": "./dist/solidtime-ui-lib.umd.cjs"
|
||||
}
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
"./style.css": "./dist/style.css",
|
||||
"./styles.css": "./styles.css",
|
||||
"./tailwind.theme.js": "./tailwind.theme.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && vue-tsc --emitDeclarationOnly",
|
||||
"watch": "vite build --watch",
|
||||
"types": "vue-tsc ",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -59,8 +62,11 @@
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@zodios/core": "^10.9.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"parse-duration": "^2.0.1",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vue": "^3.5.0",
|
||||
|
||||
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
25
resources/js/packages/ui/src/Buttons/Button.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { cn } from '../utils/cn';
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui';
|
||||
import { type ButtonVariants, buttonVariants } from '.';
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
class?: HTMLAttributes['class'];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
36
resources/js/packages/ui/src/Buttons/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export { default as Button } from './Button.vue';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-white/5',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
input: 'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent shadow-sm',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
xs: 'h-7 rounded px-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
input: 'h-[42px] px-3 py-2 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
@@ -23,7 +23,7 @@ const dateFormat = computed(() => organization?.value?.date_format);
|
||||
<div class="text-xs text-muted-foreground font-medium">
|
||||
{{ date.format('ddd') }}
|
||||
</div>
|
||||
<span>{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||
<span class="text-xs">{{ formatDate(date.toISOString(), dateFormat) }}</span>
|
||||
<span class="block text-xs text-muted-foreground font-medium mt-1">
|
||||
{{ formatHumanReadableDuration(totalSeconds, intervalFormat, numberFormat) }}
|
||||
</span>
|
||||
|
||||
@@ -40,18 +40,18 @@ const formattedDuration = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-xs leading-tight">
|
||||
<div class="font-semibold mb-0.5">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
<div class="text-2xs leading-tight px-0.5 py-1.5">
|
||||
<div class="font-semibold">{{ title }}</div>
|
||||
<div v-if="projectName" class="font-medium opacity-90">
|
||||
{{ projectName }}
|
||||
</div>
|
||||
<div v-if="taskName" class="font-medium text-[0.6875rem] opacity-90">
|
||||
<div v-if="taskName" class="font-medium">
|
||||
{{ taskName }}
|
||||
</div>
|
||||
<div v-if="clientName" class="text-[0.625rem] italic opacity-85">
|
||||
<div v-if="clientName" class="opacity-85">
|
||||
{{ clientName }}
|
||||
</div>
|
||||
<div class="text-[0.625rem] font-semibold opacity-90 mt-0.5">
|
||||
<div class="opacity-90">
|
||||
{{ formattedDuration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,17 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { DatesSetArg, EventClickArg, EventDropArg, EventChangeArg } from '@fullcalendar/core';
|
||||
import { computed, ref, watch, inject, type ComputedRef } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
watch,
|
||||
inject,
|
||||
type ComputedRef,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onActivated,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import chroma from 'chroma-js';
|
||||
import { useCssVariable } from '@/utils/useCssVariable';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
|
||||
@@ -12,6 +22,10 @@ import { getUserTimezone, getWeekStart } from '../utils/settings';
|
||||
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
|
||||
import FullCalendarEventContent from './FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
|
||||
import activityStatusPlugin, {
|
||||
type ActivityPeriod,
|
||||
renderActivityStatusBoxes,
|
||||
} from './idleStatusPlugin';
|
||||
import type {
|
||||
TimeEntry,
|
||||
Project,
|
||||
@@ -24,7 +38,10 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry } & Record<string, unknown>;
|
||||
type CalendarExtendedProps = { timeEntry: TimeEntry; isRunning?: boolean } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'dates-change', payload: { start: Date; end: Date }): void;
|
||||
@@ -37,10 +54,13 @@ const props = defineProps<{
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
tags: Tag[];
|
||||
activityPeriods?: ActivityPeriod[];
|
||||
loading?: boolean;
|
||||
|
||||
// Permissions / feature flags
|
||||
enableEstimatedTime: boolean;
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
|
||||
createTimeEntry: (
|
||||
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
|
||||
@@ -61,6 +81,10 @@ const selectedTimeEntry = ref<TimeEntry | null>(null);
|
||||
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Reactive "now" for running time entry - updates every minute
|
||||
const currentTime = ref(getDayJsInstance()());
|
||||
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Inject organization data for settings
|
||||
const organization = inject<ComputedRef<Organization>>('organization');
|
||||
|
||||
@@ -102,47 +126,52 @@ const events = computed(() => {
|
||||
const themeBackground = (() => {
|
||||
return cssBackground.value?.trim();
|
||||
})();
|
||||
return props.timeEntries
|
||||
?.filter((timeEntry) => timeEntry.end !== null)
|
||||
?.map((timeEntry) => {
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
const duration = getDayJsInstance()(timeEntry.end!).diff(
|
||||
getDayJsInstance()(timeEntry.start),
|
||||
'minutes'
|
||||
);
|
||||
return props.timeEntries?.map((timeEntry) => {
|
||||
const isRunning = timeEntry.end === null;
|
||||
const project = props.projects.find((p) => p.id === timeEntry.project_id);
|
||||
const client = props.clients.find((c) => c.id === project?.client_id);
|
||||
const task = props.tasks.find((t) => t.id === timeEntry.task_id);
|
||||
|
||||
const title = timeEntry.description || 'No description';
|
||||
// For running entries, use current time as end
|
||||
const effectiveEnd = isRunning ? currentTime.value : getDayJsInstance()(timeEntry.end!);
|
||||
const duration = effectiveEnd.diff(getDayJsInstance()(timeEntry.start), 'minutes');
|
||||
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
const title = timeEntry.description || 'No description';
|
||||
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
const baseColor = project?.color || '#6B7280';
|
||||
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
|
||||
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
},
|
||||
};
|
||||
});
|
||||
// For 0-duration events, display them with minimum visual duration but preserve actual duration
|
||||
const startTime = getLocalizedDayJs(timeEntry.start);
|
||||
const endTime =
|
||||
duration === 0
|
||||
? startTime.add(1, 'second') // Show as 1 second for minimal visibility
|
||||
: isRunning
|
||||
? getLocalizedDayJs(currentTime.value.toISOString())
|
||||
: getLocalizedDayJs(timeEntry.end!);
|
||||
|
||||
return {
|
||||
id: timeEntry.id,
|
||||
start: startTime.format(),
|
||||
end: endTime.format(),
|
||||
title,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
textColor: 'var(--foreground)',
|
||||
// For running entries: disable dragging and resizing
|
||||
startEditable: !isRunning,
|
||||
classNames: isRunning ? ['running-entry'] : [],
|
||||
extendedProps: {
|
||||
timeEntry,
|
||||
project,
|
||||
client,
|
||||
task,
|
||||
duration,
|
||||
isRunning,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Daily totals used in day header
|
||||
@@ -163,6 +192,8 @@ const dailyTotals = computed(() => {
|
||||
|
||||
function emitDatesChange(arg: DatesSetArg) {
|
||||
emit('dates-change', { start: arg.start, end: arg.end });
|
||||
// Render activity boxes after calendar view has been rendered
|
||||
renderActivityBoxes();
|
||||
}
|
||||
|
||||
function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
@@ -181,6 +212,10 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const ext = arg.event.extendedProps as CalendarExtendedProps;
|
||||
// Don't open edit modal for running time entries
|
||||
if (ext.isRunning) {
|
||||
return;
|
||||
}
|
||||
selectedTimeEntry.value = ext.timeEntry;
|
||||
showEditTimeEntryModal.value = true;
|
||||
}
|
||||
@@ -194,11 +229,13 @@ async function handleEventDrop(arg: EventDropArg) {
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
@@ -215,20 +252,25 @@ async function handleEventResize(arg: EventChangeArg) {
|
||||
start: getDayJsInstance()(arg.event.start.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
end: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.utc()
|
||||
.format(),
|
||||
// Preserve null end for running entries
|
||||
end: ext.isRunning
|
||||
? null
|
||||
: getDayJsInstance()(arg.event.end.toISOString())
|
||||
.utc()
|
||||
.tz(getUserTimezone(), true)
|
||||
.second(0)
|
||||
.utc()
|
||||
.format(),
|
||||
} as TimeEntry;
|
||||
await props.updateTimeEntry(updatedTimeEntry);
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
@@ -241,10 +283,11 @@ const calendarOptions = computed(() => ({
|
||||
slotDuration: '00:15:00',
|
||||
slotLabelInterval: '01:00:00',
|
||||
slotLabelFormat: getSlotLabelFormat(),
|
||||
snapDuration: '00:15:00',
|
||||
snapDuration: '00:01:00',
|
||||
firstDay: getFirstDay(),
|
||||
allDaySlot: false,
|
||||
nowIndicator: true,
|
||||
eventMinHeight: 1,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
editable: true,
|
||||
@@ -259,6 +302,7 @@ const calendarOptions = computed(() => ({
|
||||
datesSet: emitDatesChange,
|
||||
|
||||
events: events.value,
|
||||
activityPeriods: props.activityPeriods || [],
|
||||
}));
|
||||
|
||||
watch(showCreateTimeEntryModal, (value) => {
|
||||
@@ -277,6 +321,60 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
emit('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
// Render activity status boxes after FullCalendar has rendered
|
||||
const renderActivityBoxes = () => {
|
||||
if (!calendarRef.value || !props.activityPeriods) return;
|
||||
|
||||
const calendarEl = calendarRef.value.$el as HTMLElement;
|
||||
if (calendarEl && props.activityPeriods.length > 0) {
|
||||
renderActivityStatusBoxes(calendarEl, props.activityPeriods);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for activity periods changes - re-render when data changes
|
||||
watch(
|
||||
() => props.activityPeriods,
|
||||
() => {
|
||||
renderActivityBoxes();
|
||||
}
|
||||
);
|
||||
|
||||
const scrollToCurrentTime = () => {
|
||||
nextTick(() => {
|
||||
if (calendarRef.value) {
|
||||
const now = getDayJsInstance()();
|
||||
const oneHourBefore = now.subtract(1, 'hour');
|
||||
|
||||
// If subtracting 1 hour keeps us on the same day, scroll to 1 hour before
|
||||
const scrollTime = now.isSame(oneHourBefore, 'day')
|
||||
? oneHourBefore.format('HH:mm:ss')
|
||||
: now.format('HH:mm:ss');
|
||||
|
||||
calendarRef.value.getApi().scrollToTime(scrollTime);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
scrollToCurrentTime();
|
||||
// Start interval to update running time entry
|
||||
currentTimeInterval = setInterval(() => {
|
||||
currentTime.value = getDayJsInstance()();
|
||||
}, 60000); // Update every minute
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
scrollToCurrentTime();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up interval
|
||||
if (currentTimeInterval) {
|
||||
clearInterval(currentTimeInterval);
|
||||
currentTimeInterval = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -295,6 +393,8 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
:create-client="createClient"
|
||||
:create-project="createProject"
|
||||
:create-tag="createTag"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject"
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
@@ -314,7 +414,9 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
:tags="tags as any"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
<FullCalendar ref="calendarRef" class="fullcalendar" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<FullCalendarEventContent
|
||||
@@ -367,11 +469,11 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-slot-label) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-toolbar) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -452,8 +554,8 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-event) {
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.25rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--theme-shadow-card);
|
||||
@@ -515,7 +617,7 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-highlight) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-select-mirror) {
|
||||
@@ -533,7 +635,7 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-body) {
|
||||
background-color: var(--theme-color-default-background);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-col) {
|
||||
@@ -600,4 +702,44 @@ watch(showEditTimeEntryModal, (value) => {
|
||||
.fullcalendar :deep(.fc-event-main) {
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
|
||||
/* Activity status plugin styles */
|
||||
.fullcalendar :deep(.activity-status-box) {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.activity-status-box.idle) {
|
||||
background-color: rgba(156, 163, 175, 0.1) !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.activity-status-box.idle):hover {
|
||||
background-color: rgba(156, 163, 175, 0.5) !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.activity-status-box.active) {
|
||||
background-color: rgba(34, 197, 94, 0.3) !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.activity-status-box.active):hover {
|
||||
background-color: rgba(34, 197, 94, 1) !important;
|
||||
}
|
||||
|
||||
/* Add left margin to events only on days with activity status data */
|
||||
.fullcalendar :deep(.has-activity-status .fc-timegrid-event-harness) {
|
||||
margin-left: 15px !important;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.fc-timegrid-event) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide end resizer for running time entries */
|
||||
.fullcalendar :deep(.running-entry .fc-event-resizer-end) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fullcalendar :deep(.running-entry) {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
241
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal file
241
resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { createPlugin, type PluginDef } from '@fullcalendar/core';
|
||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
||||
|
||||
export interface ActivityPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
isIdle: boolean;
|
||||
}
|
||||
|
||||
export interface ActivityStatusPluginOptions {
|
||||
activityPeriods?: ActivityPeriod[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and manages a tooltip element for activity status boxes
|
||||
*/
|
||||
function createTooltip(): HTMLElement {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className =
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground';
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.pointerEvents = 'none';
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.whiteSpace = 'nowrap';
|
||||
tooltip.style.transform = 'scale(0.95)';
|
||||
tooltip.style.transition = 'opacity 150ms, transform 150ms';
|
||||
document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows tooltip for an activity status box
|
||||
*/
|
||||
function showTooltip(box: HTMLElement, tooltip: HTMLElement, text: string) {
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.opacity = '1';
|
||||
tooltip.style.transform = 'scale(1)';
|
||||
|
||||
const updatePosition = () => {
|
||||
computePosition(box, tooltip, {
|
||||
placement: 'right',
|
||||
middleware: [offset(8), flip(), shift({ padding: 5 })],
|
||||
}).then(({ x, y }) => {
|
||||
tooltip.style.left = `${x}px`;
|
||||
tooltip.style.top = `${y}px`;
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the tooltip
|
||||
*/
|
||||
function hideTooltip(tooltip: HTMLElement) {
|
||||
tooltip.style.opacity = '0';
|
||||
tooltip.style.transform = 'scale(0.95)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders activity status boxes in the calendar time grid
|
||||
*/
|
||||
export function renderActivityStatusBoxes(
|
||||
calendarEl: HTMLElement,
|
||||
activityPeriods: ActivityPeriod[]
|
||||
) {
|
||||
if (!calendarEl) return;
|
||||
|
||||
// Clean up existing activity boxes and markers first
|
||||
const existingBoxes = calendarEl.querySelectorAll('.activity-status-box');
|
||||
existingBoxes.forEach((box) => box.remove());
|
||||
|
||||
// Clean up existing tooltips
|
||||
const existingTooltips = document.querySelectorAll('.activity-status-tooltip');
|
||||
existingTooltips.forEach((tooltip) => tooltip.remove());
|
||||
|
||||
// Remove has-activity-status class from all lanes
|
||||
const allLanes = calendarEl.querySelectorAll('.fc-timegrid-col');
|
||||
allLanes.forEach((lane) => lane.classList.remove('has-activity-status'));
|
||||
|
||||
const timeGrid = calendarEl.querySelector('.fc-timegrid-body');
|
||||
if (!timeGrid) {
|
||||
console.log('No timegrid found');
|
||||
return;
|
||||
}
|
||||
|
||||
const lanes = timeGrid.querySelectorAll('.fc-timegrid-col');
|
||||
if (lanes.length === 0) {
|
||||
console.log('No lanes found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Rendering activity status boxes, lanes:',
|
||||
lanes.length,
|
||||
'periods:',
|
||||
activityPeriods.length
|
||||
);
|
||||
|
||||
// Create a single tooltip instance to be reused
|
||||
const tooltip = createTooltip();
|
||||
|
||||
lanes.forEach((lane: Element, dayIndex: number) => {
|
||||
// Get the date for this lane from the data attribute
|
||||
const laneEl = lane as HTMLElement;
|
||||
const dateStr = laneEl.getAttribute('data-date');
|
||||
|
||||
if (!dateStr) {
|
||||
console.log('No date attribute found for lane', dayIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const laneDate = new Date(dateStr);
|
||||
const laneDateStart = new Date(laneDate);
|
||||
laneDateStart.setHours(0, 0, 0, 0);
|
||||
const laneDateEnd = new Date(laneDate);
|
||||
laneDateEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
let hasActivityStatusForThisDay = false;
|
||||
|
||||
activityPeriods.forEach((period) => {
|
||||
const periodStart = new Date(period.start);
|
||||
const periodEnd = new Date(period.end);
|
||||
|
||||
// Check if period overlaps with this day
|
||||
if (periodEnd < laneDateStart || periodStart > laneDateEnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the position and height of the idle box
|
||||
const { top, height } = calculateBoxPosition(
|
||||
calendarEl,
|
||||
periodStart > laneDateStart ? periodStart : laneDateStart,
|
||||
periodEnd < laneDateEnd ? periodEnd : laneDateEnd
|
||||
);
|
||||
|
||||
if (height <= 0) return;
|
||||
|
||||
hasActivityStatusForThisDay = true;
|
||||
|
||||
// Create and append the activity status box
|
||||
const box = document.createElement('div');
|
||||
box.className = `activity-status-box ${period.isIdle ? 'idle' : 'active'}`;
|
||||
box.style.position = 'absolute';
|
||||
box.style.top = `${top}px`;
|
||||
box.style.height = `${height}px`;
|
||||
box.style.width = '8px';
|
||||
box.style.left = '4px';
|
||||
box.style.right = '4px';
|
||||
box.style.zIndex = '10';
|
||||
box.style.cursor = 'default';
|
||||
|
||||
// Calculate duration in minutes
|
||||
const actualStart = periodStart > laneDateStart ? periodStart : laneDateStart;
|
||||
const actualEnd = periodEnd < laneDateEnd ? periodEnd : laneDateEnd;
|
||||
const durationMs = actualEnd.getTime() - actualStart.getTime();
|
||||
const durationMinutes = Math.round(durationMs / 60000);
|
||||
|
||||
// Format duration
|
||||
const hours = Math.floor(durationMinutes / 60);
|
||||
const minutes = durationMinutes % 60;
|
||||
const durationText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
|
||||
// Add tooltip text based on status
|
||||
const status = period.isIdle ? 'Idling' : 'Active';
|
||||
const tooltipText = `${status} (${durationText})`;
|
||||
|
||||
// Add hover event listeners for tooltip
|
||||
box.addEventListener('mouseenter', () => {
|
||||
showTooltip(box, tooltip, tooltipText);
|
||||
});
|
||||
|
||||
box.addEventListener('mouseleave', () => {
|
||||
hideTooltip(tooltip);
|
||||
});
|
||||
|
||||
// Position relative to the lane
|
||||
const laneFrame = lane.querySelector('.fc-timegrid-col-frame');
|
||||
if (laneFrame) {
|
||||
laneFrame.appendChild(box);
|
||||
} else {
|
||||
console.log('No lane frame found');
|
||||
}
|
||||
});
|
||||
|
||||
// Mark this lane as having activity status if any periods were rendered
|
||||
if (hasActivityStatusForThisDay) {
|
||||
laneEl.classList.add('has-activity-status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the pixel position and height for an activity status box
|
||||
*/
|
||||
function calculateBoxPosition(
|
||||
calendarEl: HTMLElement,
|
||||
startTime: Date,
|
||||
endTime: Date
|
||||
): { top: number; height: number } {
|
||||
// Get the slot duration and slot height
|
||||
const slotsEl = calendarEl.querySelectorAll('.fc-timegrid-slot');
|
||||
if (slotsEl.length === 0) {
|
||||
console.log('No slots found');
|
||||
return { top: 0, height: 0 };
|
||||
}
|
||||
|
||||
// Calculate slot height (assuming all slots are equal height)
|
||||
const firstSlot = slotsEl[0] as HTMLElement;
|
||||
const slotHeight = firstSlot.offsetHeight;
|
||||
|
||||
// Each slot is 15 minutes by default (configured in TimeEntryCalendar)
|
||||
const slotDurationMinutes = 15;
|
||||
const pixelsPerMinute = slotHeight / slotDurationMinutes;
|
||||
|
||||
// Calculate start position (minutes from midnight)
|
||||
const startMinutes = startTime.getHours() * 60 + startTime.getMinutes();
|
||||
const endMinutes = endTime.getHours() * 60 + endTime.getMinutes();
|
||||
|
||||
// Calculate pixel positions
|
||||
const top = startMinutes * pixelsPerMinute;
|
||||
const height = (endMinutes - startMinutes) * pixelsPerMinute;
|
||||
|
||||
return { top, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar plugin to display idle/active status boxes in the time grid
|
||||
*/
|
||||
const activityStatusPlugin: PluginDef = createPlugin({
|
||||
name: '@solidtime/activity-status',
|
||||
|
||||
optionRefiners: {
|
||||
activityPeriods: (rawVal: unknown): ActivityPeriod[] => {
|
||||
if (!Array.isArray(rawVal)) return [];
|
||||
return rawVal as ActivityPeriod[];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default activityStatusPlugin;
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/Components/ui/popover';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import Button from '../Buttons/Button.vue';
|
||||
import { RangeCalendar } from '@/Components/ui/range-calendar';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { CalendarIcon } from 'lucide-vue-next';
|
||||
|
||||
@@ -27,11 +27,19 @@ function updateTime(event: Event) {
|
||||
if (newValue.split(':').length === 2) {
|
||||
const [hours, minutes] = newValue.split(':');
|
||||
if (!isNaN(parseInt(hours)) && !isNaN(parseInt(minutes))) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
const currentTime = getLocalizedDayJs(model.value);
|
||||
const newHours = Math.min(parseInt(hours), 23);
|
||||
const newMinutes = Math.min(parseInt(minutes), 59);
|
||||
|
||||
// Only update if hours or minutes are different
|
||||
if (currentTime.hour() !== newHours || currentTime.minute() !== newMinutes) {
|
||||
model.value = currentTime
|
||||
.set('hours', newHours)
|
||||
.set('minutes', newMinutes)
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// check if input is only numbers
|
||||
@@ -42,6 +50,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 3) {
|
||||
@@ -50,6 +59,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(hours), 23))
|
||||
.set('minutes', Math.min(parseInt(minutes), 59))
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 2) {
|
||||
@@ -57,6 +67,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
} else if (newValue.length === 1) {
|
||||
@@ -64,6 +75,7 @@ function updateTime(event: Event) {
|
||||
model.value = getLocalizedDayJs(model.value)
|
||||
.set('hours', Math.min(parseInt(newValue), 23))
|
||||
.set('minutes', 0)
|
||||
.set('seconds', 0)
|
||||
.format();
|
||||
emit('changed', model.value);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
|
||||
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
|
||||
import dayjs from 'dayjs';
|
||||
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import Button from '../Buttons/Button.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
start: string;
|
||||
|
||||
@@ -93,7 +93,7 @@ function onSelectChange(checked: boolean) {
|
||||
class="border-b border-default-background-separator bg-row-background min-w-0 transition"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="@sm:flex py-2 items-center min-w-0 justify-between group">
|
||||
<div class="flex space-x-3 items-center min-w-0">
|
||||
<Checkbox
|
||||
:checked="
|
||||
@@ -125,7 +125,7 @@ function onSelectChange(checked: boolean) {
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center font-medium lg:space-x-2">
|
||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
||||
<TimeEntryRowTagDropdown
|
||||
:create-tag
|
||||
:tags="tags"
|
||||
@@ -142,8 +142,8 @@ function onSelectChange(checked: boolean) {
|
||||
twMerge(
|
||||
'text-text-secondary px-1 py-1.5 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
organization?.time_format === '12-hours'
|
||||
? 'w-[170px]'
|
||||
: 'w-[120px]'
|
||||
? 'w-[160px]'
|
||||
: 'w-[100px]'
|
||||
)
|
||||
"
|
||||
@click="expanded = !expanded">
|
||||
@@ -157,7 +157,7 @@ function onSelectChange(checked: boolean) {
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="text-text-primary min-w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
||||
class="text-text-primary !mr-2 min-w-[80px] px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-tertiary"
|
||||
@click="expanded = !expanded">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
Client,
|
||||
CreateTimeEntryBody,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
@@ -43,6 +41,8 @@ const props = defineProps<{
|
||||
clients: Client[];
|
||||
start?: string;
|
||||
end?: string;
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -61,8 +61,8 @@ const timeEntryDefaultValues = {
|
||||
task_id: null,
|
||||
tags: [],
|
||||
billable: false,
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').format(),
|
||||
end: getDayJsInstance().utc().format(),
|
||||
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
|
||||
end: getDayJsInstance().utc().second(0).format(),
|
||||
};
|
||||
|
||||
const timeEntry = ref({
|
||||
@@ -167,8 +167,8 @@ type BillableOption = {
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project
|
||||
:currency
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
Client,
|
||||
TimeEntry,
|
||||
} from '@/packages/api/src';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
|
||||
import { Badge } from '@/packages/ui/src';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
@@ -44,6 +42,8 @@ const props = defineProps<{
|
||||
projects: Project[];
|
||||
tasks: Task[];
|
||||
clients: Client[];
|
||||
currency: string;
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const description = ref<HTMLInputElement | null>(null);
|
||||
@@ -163,8 +163,8 @@ type BillableOption = {
|
||||
:clients
|
||||
:create-project
|
||||
:create-client
|
||||
:can-create-project="canCreateProjects()"
|
||||
:currency="getOrganizationCurrencyString()"
|
||||
:can-create-project="canCreateProject"
|
||||
:currency="currency"
|
||||
size="xlarge"
|
||||
class="bg-input-background"
|
||||
:projects="projects"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
CreateClientBody,
|
||||
CreateProjectBody,
|
||||
@@ -38,6 +38,8 @@ const props = defineProps<{
|
||||
canCreateProject: boolean;
|
||||
}>();
|
||||
|
||||
const maxVisibleGroups = ref(7); // Start with 10 day groups, then show all
|
||||
|
||||
const groupedTimeEntries = computed(() => {
|
||||
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
|
||||
for (const entry of props.timeEntries) {
|
||||
@@ -94,6 +96,7 @@ const groupedTimeEntries = computed(() => {
|
||||
|
||||
groupedEntriesByDayAndType[dailyEntriesKey] = newDailyEntries;
|
||||
}
|
||||
|
||||
return groupedEntriesByDayAndType;
|
||||
});
|
||||
|
||||
@@ -134,82 +137,116 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const visibleGroupedEntries = computed(() => {
|
||||
const allGroups = Object.entries(groupedTimeEntries.value);
|
||||
return Object.fromEntries(allGroups.slice(0, maxVisibleGroups.value));
|
||||
});
|
||||
|
||||
const totalGroups = computed(() => Object.keys(groupedTimeEntries.value).length);
|
||||
|
||||
function startProgressiveLoading() {
|
||||
const loadMoreGroups = () => {
|
||||
if (maxVisibleGroups.value < totalGroups.value) {
|
||||
maxVisibleGroups.value = Math.min(maxVisibleGroups.value + 5, totalGroups.value);
|
||||
|
||||
if (maxVisibleGroups.value < totalGroups.value) {
|
||||
requestIdleCallback(loadMoreGroups);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
requestIdleCallback(loadMoreGroups);
|
||||
}
|
||||
|
||||
// Watch for changes to totalGroups and adjust maxVisibleGroups accordingly
|
||||
watch(totalGroups, (newTotal, oldTotal) => {
|
||||
if (newTotal !== oldTotal) {
|
||||
maxVisibleGroups.value = newTotal;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
startProgressiveLoading();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(value, key) in groupedTimeEntries" :key="key">
|
||||
<TimeEntryRowHeading
|
||||
:date="key"
|
||||
:duration="sumDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@select-all="selectAllTimeEntries(value)"
|
||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||
:create-project
|
||||
:can-create-project
|
||||
:enable-estimated-time
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
:create-client
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:on-start-stop-click="startTimeEntryFromExisting"
|
||||
:duplicate-time-entry="createTimeEntry"
|
||||
:update-time-entries
|
||||
:update-time-entry
|
||||
:delete-time-entries
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||
}
|
||||
<div class="@container">
|
||||
<div v-for="(value, key) in visibleGroupedEntries" :key="key">
|
||||
<TimeEntryRowHeading
|
||||
:date="String(key)"
|
||||
:duration="sumDuration(value)"
|
||||
:checked="
|
||||
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
|
||||
"
|
||||
@unselected="
|
||||
(timeEntriesToUnselect: TimeEntry[]) => {
|
||||
@select-all="selectAllTimeEntries(value)"
|
||||
@unselect-all="unselectAllTimeEntries(value)"></TimeEntryRowHeading>
|
||||
<template v-for="entry in value" :key="entry.id">
|
||||
<TimeEntryAggregateRow
|
||||
v-if="'timeEntries' in entry && entry.timeEntries.length > 1"
|
||||
:create-project
|
||||
:can-create-project
|
||||
:enable-estimated-time
|
||||
:selected-time-entries="selectedTimeEntries"
|
||||
:create-client
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:on-start-stop-click="startTimeEntryFromExisting"
|
||||
:duplicate-time-entry="createTimeEntry"
|
||||
:update-time-entries
|
||||
:update-time-entry
|
||||
:delete-time-entries
|
||||
:create-tag
|
||||
:currency="currency"
|
||||
:time-entry="entry"
|
||||
@selected="
|
||||
(timeEntries: TimeEntry[]) => {
|
||||
selectedTimeEntries = [...selectedTimeEntries, ...timeEntries];
|
||||
}
|
||||
"
|
||||
@unselected="
|
||||
(timeEntriesToUnselect: TimeEntry[]) => {
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow
|
||||
v-else
|
||||
:create-client
|
||||
:enable-estimated-time
|
||||
:can-create-project
|
||||
:create-project
|
||||
:projects="projects"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
||||
)
|
||||
"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:create-tag
|
||||
:update-time-entry
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
@unselected="
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) =>
|
||||
!timeEntriesToUnselect.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === item.id
|
||||
)
|
||||
);
|
||||
}
|
||||
"></TimeEntryAggregateRow>
|
||||
<TimeEntryRow
|
||||
v-else
|
||||
:create-client
|
||||
:enable-estimated-time
|
||||
:can-create-project
|
||||
:create-project
|
||||
:projects="projects"
|
||||
:selected="
|
||||
!!selectedTimeEntries.find(
|
||||
(filterEntry: TimeEntry) => filterEntry.id === entry.id
|
||||
)
|
||||
"
|
||||
:tasks="tasks"
|
||||
:tags="tags"
|
||||
:clients
|
||||
:create-tag
|
||||
:update-time-entry
|
||||
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
|
||||
:delete-time-entry="() => deleteTimeEntries([entry])"
|
||||
:duplicate-time-entry="() => createTimeEntry(entry)"
|
||||
:currency="currency"
|
||||
:time-entry="entry.timeEntries[0]"
|
||||
@selected="selectedTimeEntries.push(entry)"
|
||||
@unselected="
|
||||
selectedTimeEntries = selectedTimeEntries.filter(
|
||||
(item: TimeEntry) => item.id !== entry.id
|
||||
)
|
||||
"></TimeEntryRow>
|
||||
</template>
|
||||
(item: TimeEntry) => item.id !== entry.id
|
||||
)
|
||||
"></TimeEntryRow>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ const organization = inject<ComputedRef<Organization>>('organization');
|
||||
data-testid="time_entry_range_selector"
|
||||
:class="
|
||||
twMerge(
|
||||
'text-text-secondary px-2 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
|
||||
showDate
|
||||
? 'text-xs py-1.5 font-semibold'
|
||||
: 'text-sm py-1.5 font-medium',
|
||||
organization?.time_format === '12-hours' ? 'w-[170px]' : 'w-[120px]',
|
||||
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
|
||||
open && 'border-card-border bg-card-background'
|
||||
)
|
||||
">
|
||||
|
||||
@@ -112,7 +112,7 @@ async function handleDeleteTimeEntry() {
|
||||
class="border-b border-default-background-separator transition min-w-0 bg-row-background"
|
||||
data-testid="time_entry_row">
|
||||
<MainContainer class="min-w-0">
|
||||
<div class="sm:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="@sm:flex py-2 min-w-0 items-center justify-between group">
|
||||
<div class="flex items-center min-w-0">
|
||||
<Checkbox :checked="selected" @update:checked="onSelectChange" />
|
||||
<div v-if="indent === true" class="w-10 h-7"></div>
|
||||
@@ -134,7 +134,7 @@ async function handleDeleteTimeEntry() {
|
||||
:task="timeEntry.task_id"
|
||||
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
|
||||
</div>
|
||||
<div class="flex items-center font-medium space-x-1 lg:space-x-2">
|
||||
<div class="flex items-center font-medium space-x-1 @lg:space-x-2">
|
||||
<div v-if="showMember && members" class="text-sm px-2">
|
||||
{{ memberName }}
|
||||
</div>
|
||||
@@ -186,7 +186,9 @@ async function handleDeleteTimeEntry() {
|
||||
:tags="tags"
|
||||
:projects="projects"
|
||||
:tasks="tasks"
|
||||
:clients="clients" />
|
||||
:clients="clients"
|
||||
:currency="currency"
|
||||
:can-create-project="canCreateProject" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -77,7 +77,7 @@ function selectInput(event: Event) {
|
||||
v-model="currentTime"
|
||||
data-testid="time_entry_duration_input"
|
||||
name="Duration"
|
||||
class="text-text-primary w-[90px] px-2.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="text-text-primary w-[80px] !mr-2 px-1.5 py-1.5 bg-transparent text-right hover:bg-card-background rounded-lg border border-transparent hover:border-card-border text-sm font-medium focus-visible:bg-tertiary focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@focus="selectInput"
|
||||
@keydown.tab="open = false"
|
||||
@blur="updateTimerAndStartLiveTimerUpdate"
|
||||
|
||||
@@ -32,13 +32,13 @@ function selectUnselectAll(value: boolean) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs sm:text-sm">
|
||||
class="bg-row-heading-background border-t border-b border-row-heading-border py-1 text-xs @sm:text-sm">
|
||||
<MainContainer>
|
||||
<div class="flex group justify-between items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-5">
|
||||
<svg
|
||||
class="w-3 sm:w-4 text-icon-default group-hover:hidden block"
|
||||
class="w-3 @sm:w-4 text-icon-default group-hover:hidden block"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none">
|
||||
@@ -61,7 +61,7 @@ function selectUnselectAll(value: boolean) {
|
||||
{{ formatDate(date, organization?.date_format) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-secondary pr-[90px] lg:pr-[92px]">
|
||||
<div class="text-text-secondary pr-[87px] @lg:pr-[92px]">
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
} from '@/packages/api/src';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useTimeEntriesStore } from '@/utils/useTimeEntries';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useFocus } from '@vueuse/core';
|
||||
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
|
||||
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
|
||||
@@ -34,6 +32,7 @@ const props = defineProps<{
|
||||
tasks: Task[];
|
||||
tags: Tag[];
|
||||
clients: Client[];
|
||||
timeEntries: TimeEntry[];
|
||||
createTag: (name: string) => Promise<Tag | undefined>;
|
||||
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
|
||||
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
|
||||
@@ -131,10 +130,9 @@ function updateTimeEntryDescription() {
|
||||
}
|
||||
}
|
||||
|
||||
const { timeEntries } = storeToRefs(useTimeEntriesStore());
|
||||
const filteredRecentlyTrackedTimeEntries = computed(() => {
|
||||
// do not include running time entries
|
||||
const finishedTimeEntries = timeEntries.value.filter((item) => item.end !== null);
|
||||
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
|
||||
|
||||
// filter out duplicates based on description, task, project, tags and billable
|
||||
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
|
||||
|
||||
@@ -10,8 +10,12 @@ import * as color from './utils/color';
|
||||
import * as random from './utils/random';
|
||||
import * as time from './utils/time';
|
||||
|
||||
export { cn } from './utils/cn';
|
||||
export { buttonVariants, type ButtonVariants } from './Buttons/index';
|
||||
|
||||
import PrimaryButton from './Buttons/PrimaryButton.vue';
|
||||
import SecondaryButton from './Buttons/SecondaryButton.vue';
|
||||
import Button from './Buttons/Button.vue';
|
||||
import TimeTrackerStartStop from './TimeTrackerStartStop.vue';
|
||||
import ProjectBadge from './Project/ProjectBadge.vue';
|
||||
import LoadingSpinner from './LoadingSpinner.vue';
|
||||
@@ -20,6 +24,7 @@ import TextInput from './Input/TextInput.vue';
|
||||
import InputLabel from './Input/InputLabel.vue';
|
||||
import TimeTrackerRunningInDifferentOrganizationOverlay from './TimeTracker/TimeTrackerRunningInDifferentOrganizationOverlay.vue';
|
||||
import TimeTrackerControls from './TimeTracker/TimeTrackerControls.vue';
|
||||
import TimeTrackerMoreOptionsDropdown from './TimeTracker/TimeTrackerMoreOptionsDropdown.vue';
|
||||
import CardTitle from './CardTitle.vue';
|
||||
import SelectDropdown from './Input/SelectDropdown.vue';
|
||||
import Badge from './Badge.vue';
|
||||
@@ -32,12 +37,15 @@ import MoreOptionsDropdown from './MoreOptionsDropdown.vue';
|
||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||
import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin';
|
||||
|
||||
export {
|
||||
money,
|
||||
color,
|
||||
random,
|
||||
time,
|
||||
Button,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
TimeTrackerStartStop,
|
||||
@@ -48,6 +56,7 @@ export {
|
||||
InputLabel,
|
||||
TimeTrackerRunningInDifferentOrganizationOverlay,
|
||||
TimeTrackerControls,
|
||||
TimeTrackerMoreOptionsDropdown,
|
||||
CardTitle,
|
||||
SelectDropdown,
|
||||
Badge,
|
||||
@@ -60,4 +69,8 @@ export {
|
||||
FullCalendarEventContent,
|
||||
FullCalendarDayHeader,
|
||||
TimeEntryCalendar,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
};
|
||||
|
||||
15
resources/js/packages/ui/src/tooltip/Tooltip.vue
Normal file
15
resources/js/packages/ui/src/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui';
|
||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
const props = defineProps<TooltipRootProps>();
|
||||
const emits = defineEmits<TooltipRootEmits>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
39
resources/js/packages/ui/src/tooltip/TooltipContent.vue
Normal file
39
resources/js/packages/ui/src/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
}
|
||||
);
|
||||
|
||||
const emits = defineEmits<TooltipContentEmits>();
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
12
resources/js/packages/ui/src/tooltip/TooltipProvider.vue
Normal file
12
resources/js/packages/ui/src/tooltip/TooltipProvider.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipProviderProps } from 'reka-ui';
|
||||
import { TooltipProvider } from 'reka-ui';
|
||||
|
||||
const props = defineProps<TooltipProviderProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="props">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
12
resources/js/packages/ui/src/tooltip/TooltipTrigger.vue
Normal file
12
resources/js/packages/ui/src/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipTriggerProps } from 'reka-ui';
|
||||
import { TooltipTrigger } from 'reka-ui';
|
||||
|
||||
const props = defineProps<TooltipTriggerProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger v-bind="props">
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
4
resources/js/packages/ui/src/tooltip/index.ts
Normal file
4
resources/js/packages/ui/src/tooltip/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Tooltip } from './Tooltip.vue';
|
||||
export { default as TooltipContent } from './TooltipContent.vue';
|
||||
export { default as TooltipProvider } from './TooltipProvider.vue';
|
||||
export { default as TooltipTrigger } from './TooltipTrigger.vue';
|
||||
6
resources/js/packages/ui/src/utils/cn.ts
Normal file
6
resources/js/packages/ui/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs.filter(Boolean)));
|
||||
}
|
||||
240
resources/js/packages/ui/styles.css
Normal file
240
resources/js/packages/ui/styles.css
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Shared styles for solidtime
|
||||
* This CSS file contains all the shared theme variables and base styles
|
||||
* used by both the main solidtime app and the desktop app.
|
||||
*
|
||||
* Font-face declarations are intentionally omitted here as they differ between apps:
|
||||
* - Main app uses 'Inter'
|
||||
* - Desktop app uses 'Outfit'
|
||||
* Each app should include their own font-face declarations.
|
||||
*/
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root.dark {
|
||||
--color-bg-primary: #101012;
|
||||
--color-bg-secondary: #17181b;
|
||||
--color-bg-tertiary: #2a2c32;
|
||||
--color-bg-quaternary: #141518;
|
||||
--color-bg-background: #090909;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #e3e4e6;
|
||||
--color-text-tertiary: #969799;
|
||||
--color-text-quaternary: #595a5c;
|
||||
|
||||
--color-border-primary: #191b1f;
|
||||
--color-border-secondary: #23252a;
|
||||
--color-border-tertiary: #2c2e33;
|
||||
--color-border-quaternary: #393b42;
|
||||
--color-input-border-active: rgba(255, 255, 255, 0.3);
|
||||
|
||||
--theme-color-chart: var(--color-accent-200);
|
||||
|
||||
--theme-color-menu-active: var(--color-bg-secondary);
|
||||
--theme-color-card-background: var(--color-bg-secondary);
|
||||
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
|
||||
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%);
|
||||
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-row-background: var(--color-bg-primary);
|
||||
--theme-color-row-heading-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-border: var(--theme-color-card-border);
|
||||
--theme-color-icon-default: var(--color-text-tertiary);
|
||||
|
||||
--theme-color-ring: rgba(255, 255, 255, 0.5);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-300), 0.1);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-300), 0.2);
|
||||
--theme-color-button-primary-text: var(--color-text-primary);
|
||||
|
||||
--theme-color-input-background: var(--color-bg-secondary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-300));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-200));
|
||||
|
||||
--color-accent-default: rgba(var(--color-accent-300), 0.2);
|
||||
--color-accent-foreground: rgb(var(--color-accent-100));
|
||||
--theme-color-default-background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
:root.light {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f7f7f8;
|
||||
--color-bg-tertiary: #eeeeef;
|
||||
--color-bg-quaternary: #e1e1e3;
|
||||
--color-bg-background: #f5f5f5;
|
||||
--color-text-primary: #18181b;
|
||||
--color-text-secondary: #3f3f46;
|
||||
--color-text-tertiary: #57575c;
|
||||
--color-text-quaternary: #a1a1aa;
|
||||
--color-border-primary: #e7e7e7;
|
||||
--color-border-secondary: #e5e5e5;
|
||||
--color-border-tertiary: #dfdfdf;
|
||||
--color-border-quaternary: #d1d1d1;
|
||||
--color-input-border-active: rgba(0, 0, 0, 0.3);
|
||||
--theme-color-menu-active: var(--color-bg-quaternary);
|
||||
|
||||
--theme-color-card-background: var(--color-bg-primary);
|
||||
--theme-color-card-background-active: var(--color-bg-tertiary);
|
||||
|
||||
--theme-color-chart: var(--color-accent-400);
|
||||
|
||||
--theme-shadow-card: lch(0 0 0 / 0.022) 0px 3px 6px -2px, lch(0 0 0 / 0.044) 0px 1px 1px;
|
||||
--theme-shadow-dropdown: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
|
||||
--theme-color-row-background: var(--theme-color-card-background);
|
||||
--theme-color-row-heading-background: var(--color-bg-secondary);
|
||||
--theme-color-row-heading-border: var(--color-border-tertiary);
|
||||
--theme-color-icon-default: var(--color-text-quaternary);
|
||||
|
||||
--theme-color-ring: rgba(0, 0, 0, 0.7);
|
||||
|
||||
--theme-color-button-primary-background: rgba(var(--color-accent-600), 0.9);
|
||||
--theme-color-button-primary-background-hover: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-border: rgba(var(--color-accent-600), 1);
|
||||
--theme-color-button-primary-text: #ffffff;
|
||||
|
||||
--theme-color-input-background: var(--color-bg-primary);
|
||||
|
||||
--theme-color-input-select-active: rgb(var(--color-accent-400));
|
||||
--theme-color-input-select-active-hover: rgb(var(--color-accent-500));
|
||||
|
||||
--color-accent-default: rgb(var(--color-accent-100));
|
||||
--color-accent-foreground: rgb(var(--color-accent-800));
|
||||
--theme-color-default-background: #fcfcfc;
|
||||
}
|
||||
|
||||
:root {
|
||||
--theme-color-icon-active: rgb(var(--color-text-tertiary));
|
||||
--theme-color-card-background-separator: var(--color-border-tertiary);
|
||||
--theme-color-card-border: var(--color-border-secondary);
|
||||
--theme-color-card-border-active: var(--color-border-tertiary);
|
||||
--theme-color-default-background-separator: var(--color-border-primary);
|
||||
--theme-color-primary-text: var(--color-text-primary);
|
||||
--theme-color-input-border: var(--color-border-quaternary);
|
||||
--theme-color-tab-background: var(--theme-color-card-background);
|
||||
--theme-color-tab-background-active: var(--theme-color-card-background-active);
|
||||
--theme-color-tab-border: var(--theme-color-card-border);
|
||||
--theme-color-row-separator-background: var(--theme-color-default-background-separator);
|
||||
--theme-color-row-border: var(--theme-color-card-border);
|
||||
|
||||
--color-accent-50: 240, 249, 255; /* sky-50 */
|
||||
--color-accent-100: 224, 242, 254; /* sky-100 */
|
||||
--color-accent-200: 186, 230, 253; /* sky-200 */
|
||||
--color-accent-300: 125, 211, 252; /* sky-300 */
|
||||
--color-accent-400: 56, 189, 248; /* sky-400 */
|
||||
--color-accent-500: 14, 165, 233; /* sky-500 */
|
||||
--color-accent-600: 2, 132, 199; /* sky-600 */
|
||||
--color-accent-700: 3, 105, 161; /* sky-700 */
|
||||
--color-accent-800: 7, 89, 133; /* sky-800 */
|
||||
--color-accent-900: 12, 74, 110; /* sky-900 */
|
||||
--color-accent-950: 8, 47, 73; /* sky-950 */
|
||||
|
||||
--theme-button-secondary-background: var(--theme-color-card-background);
|
||||
--theme-button-secondary-background-active: var(--theme-color-card-background-active);
|
||||
--popover-border: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track,
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--theme-color-default-background);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--color-bg-primary);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-400);
|
||||
--chart-2: var(--color-accent-500);
|
||||
--chart-3: var(--color-accent-600);
|
||||
--chart-4: var(--color-accent-700);
|
||||
--chart-5: var(--color-accent-800);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: var(--color-bg-background);
|
||||
--foreground: var(--color-text-primary);
|
||||
--card: var(--theme-color-card-background);
|
||||
--card-foreground: var(--color-text-primary);
|
||||
--popover: var(--theme-color-card-background);
|
||||
--popover-foreground: var(--color-text-primary);
|
||||
--primary: var(--color-bg-primary);
|
||||
--primary-foreground: var(--theme-color-button-primary-text);
|
||||
--secondary: var(--color-bg-secondary);
|
||||
--secondary-foreground: var(--color-text-primary);
|
||||
--muted: var(--color-bg-tertiary);
|
||||
--muted-foreground: var(--color-text-tertiary);
|
||||
--accent: var(--theme-color-button-primary-background);
|
||||
--accent-foreground: var(--theme-color-button-primary-text);
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: var(--color-text-primary);
|
||||
--border: var(--color-border-primary);
|
||||
--input: var(--color-border-tertiary);
|
||||
--ring: var(--theme-color-ring);
|
||||
--chart-1: var(--color-accent-200);
|
||||
--chart-2: var(--color-accent-300);
|
||||
--chart-3: var(--color-accent-400);
|
||||
--chart-4: var(--color-accent-500);
|
||||
--chart-5: var(--color-accent-600);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
131
resources/js/packages/ui/tailwind.theme.js
Normal file
131
resources/js/packages/ui/tailwind.theme.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Shared Tailwind theme configuration for solidtime
|
||||
* This configuration is used by both the main solidtime app and the desktop app
|
||||
*
|
||||
* Note: fontFamily is intentionally omitted here as it differs between apps:
|
||||
* - Main app uses 'Inter'
|
||||
* - Desktop app uses 'Outfit'
|
||||
* Each app should override the fontFamily in their own config.
|
||||
*/
|
||||
export const solidtimeTheme = {
|
||||
boxShadow: {
|
||||
card: 'var(--theme-shadow-card)',
|
||||
dropdown: 'var(--theme-shadow-dropdown)',
|
||||
},
|
||||
containers: {
|
||||
'2xs': '16rem',
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': ['0.625rem', { lineHeight: '0.75rem' }],
|
||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
||||
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
lg: ['1rem', { lineHeight: '1.5rem' }],
|
||||
xl: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'3xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
|
||||
'5xl': ['2rem', { lineHeight: '1' }],
|
||||
'6xl': ['2.25rem', { lineHeight: '1' }],
|
||||
'7xl': ['2.5rem', { lineHeight: '1' }],
|
||||
'8xl': ['3rem', { lineHeight: '1' }],
|
||||
'9xl': ['3.5rem', { lineHeight: '1' }],
|
||||
},
|
||||
colors: {
|
||||
ring: 'var(--ring)',
|
||||
primary: {
|
||||
DEFAULT: 'var(--primary)',
|
||||
foreground: 'var(--primary-foreground)',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
tertiary: 'var(--color-bg-tertiary)',
|
||||
quaternary: 'var(--color-bg-quaternary)',
|
||||
background: 'var(--background)',
|
||||
'text-primary': 'var(--color-text-primary)',
|
||||
'text-secondary': 'var(--color-text-secondary)',
|
||||
'text-tertiary': 'var(--color-text-tertiary)',
|
||||
'text-quaternary': 'var(--color-text-quaternary)',
|
||||
'border-primary': 'var(--color-border-primary)',
|
||||
'border-secondary': 'var(--color-border-secondary)',
|
||||
'border-tertiary': 'var(--color-border-tertiary)',
|
||||
'default-background': 'var(--theme-color-default-background)',
|
||||
'default-background-separator': 'var(--theme-color-default-background-separator)',
|
||||
'row-background': 'var(--theme-color-row-background)',
|
||||
'card-background': 'var(--theme-color-card-background)',
|
||||
'card-background-active': 'var(--theme-color-card-background-active)',
|
||||
'card-background-separator': 'var(--theme-color-card-background-separator)',
|
||||
'card-border': 'var(--theme-color-card-border)',
|
||||
'card-border-active': 'var(--theme-color-card-border-active)',
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
'tab-background': 'var(--theme-color-tab-background)',
|
||||
'tab-background-active': 'var(--theme-color-tab-background-active)',
|
||||
'tab-border': 'var(--theme-color-tab-border)',
|
||||
'icon-default': 'var(--theme-color-icon-default)',
|
||||
'icon-active': 'var(--theme-color-icon-active)',
|
||||
'menu-active': 'var(--theme-color-menu-active)',
|
||||
'input-border': 'var(--theme-color-input-border)',
|
||||
'input-border-active': 'var(--color-input-border-active)',
|
||||
'input-background': 'var(--theme-color-input-background)',
|
||||
'button-secondary-background': 'var(--theme-button-secondary-background)',
|
||||
'button-secondary-background-hover': 'var(--theme-button-secondary-background-active)',
|
||||
'button-secondary-border': 'var(--theme-color-card-border)',
|
||||
'row-separator': 'var(--theme-color-row-separator-background)',
|
||||
'row-heading-background': 'var(--theme-color-row-heading-background)',
|
||||
'row-heading-border': 'var(--theme-color-row-heading-border)',
|
||||
accent: {
|
||||
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
|
||||
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
|
||||
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
|
||||
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
|
||||
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
|
||||
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
|
||||
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
|
||||
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
|
||||
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
|
||||
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
|
||||
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
|
||||
DEFAULT: 'var(--color-accent-default)',
|
||||
foreground: 'var(--color-accent-foreground)',
|
||||
},
|
||||
'button-primary-background': 'var(--theme-color-button-primary-background)',
|
||||
'button-primary-background-hover': 'var(--theme-color-button-primary-background-hover)',
|
||||
'button-primary-border': 'var(--theme-color-button-primary-border)',
|
||||
'button-primary-text': 'var(--theme-color-button-primary-text)',
|
||||
'input-select-active': 'var(--theme-color-input-select-active)',
|
||||
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
|
||||
foreground: 'var(--foreground)',
|
||||
card: {
|
||||
DEFAULT: 'var(--card))',
|
||||
foreground: 'var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'var(--popover)',
|
||||
foreground: 'var(--popover-foreground)',
|
||||
border: 'var(--popover-border)',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'var(--destructive)',
|
||||
foreground: 'var(--destructive-foreground)',
|
||||
},
|
||||
border: 'var(--border)',
|
||||
input: 'var(--input)',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
};
|
||||
@@ -81,7 +81,7 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
router.get(route('login'));
|
||||
router.get('/login');
|
||||
}
|
||||
} else {
|
||||
addNotification('error', 'The action failed. Please try again later.');
|
||||
|
||||
@@ -1,225 +1,246 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import {
|
||||
api,
|
||||
type CreateTimeEntryBody,
|
||||
type TimeEntriesQueryParams,
|
||||
type TimeEntry,
|
||||
import { reactive, ref, type Ref } from 'vue';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type {
|
||||
CreateTimeEntryBody,
|
||||
TimeEntriesQueryParams,
|
||||
TimeEntry,
|
||||
UpdateMultipleTimeEntriesChangeset,
|
||||
} from '@/packages/api/src';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import type { UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
|
||||
import type {} from '@/packages/api/src';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
|
||||
export const useTimeEntriesStore = defineStore('timeEntries', () => {
|
||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
||||
export const useTimeEntriesStore = defineStore(
|
||||
'timeEntries',
|
||||
(): {
|
||||
timeEntries: Ref<TimeEntry[]>;
|
||||
fetchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
|
||||
updateTimeEntry: (timeEntry: TimeEntry) => Promise<void>;
|
||||
createTimeEntry: (timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
|
||||
deleteTimeEntry: (timeEntryId: string) => Promise<void>;
|
||||
fetchMoreTimeEntries: () => Promise<void>;
|
||||
allTimeEntriesLoaded: Ref<boolean>;
|
||||
updateTimeEntries: (
|
||||
ids: string[],
|
||||
changes: UpdateMultipleTimeEntriesChangeset
|
||||
) => Promise<void>;
|
||||
deleteTimeEntries: (timeEntries: TimeEntry[]) => Promise<void>;
|
||||
patchTimeEntries: (queryParams?: TimeEntriesQueryParams) => Promise<void>;
|
||||
} => {
|
||||
const timeEntries = ref<TimeEntry[]>(reactive([]));
|
||||
|
||||
const allTimeEntriesLoaded = ref(false);
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
const allTimeEntriesLoaded = ref(false);
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
async function patchTimeEntries(
|
||||
queryParams: TimeEntriesQueryParams = {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
}
|
||||
) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: queryParams,
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data) {
|
||||
// insert missing time entries
|
||||
const missingTimeEntries = timeEntriesResponse.data.filter(
|
||||
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
|
||||
);
|
||||
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
|
||||
async function patchTimeEntries(
|
||||
queryParams: TimeEntriesQueryParams = {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
|
||||
async function fetchTimeEntries(
|
||||
queryParams: TimeEntriesQueryParams = {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
}
|
||||
) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: queryParams,
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data) {
|
||||
timeEntries.value = timeEntriesResponse.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMoreTimeEntries() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
|
||||
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
|
||||
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
end: dayjs(latestTimeEntry.start).utc().format(),
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
|
||||
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
|
||||
} else {
|
||||
allTimeEntriesLoaded.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTimeEntries(ids: string[], changes: UpdateMultipleTimeEntriesChangeset) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateMultipleTimeEntries(
|
||||
{
|
||||
ids: ids,
|
||||
changes: changes,
|
||||
},
|
||||
{
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Time entries updated successfully',
|
||||
'Failed to update time entries'
|
||||
);
|
||||
queries: queryParams,
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data) {
|
||||
// insert missing time entries
|
||||
const missingTimeEntries = timeEntriesResponse.data.filter(
|
||||
(entry) => !timeEntries.value.find((e) => e.id === entry.id)
|
||||
);
|
||||
timeEntries.value = [...missingTimeEntries, ...timeEntries.value];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTimeEntry(timeEntry: TimeEntry) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateTimeEntry(timeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntry.id,
|
||||
},
|
||||
}),
|
||||
'Time entry updated successfully',
|
||||
'Failed to update time entry'
|
||||
);
|
||||
timeEntries.value = timeEntries.value.map((entry) =>
|
||||
entry.id === timeEntry.id ? response.data : entry
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
||||
async function fetchTimeEntries(
|
||||
queryParams: TimeEntriesQueryParams = {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
}
|
||||
) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
|
||||
if (organizationId) {
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: queryParams,
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data) {
|
||||
timeEntries.value = timeEntriesResponse.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (organizationId && memberId !== undefined) {
|
||||
const newTimeEntry = {
|
||||
...timeEntry,
|
||||
member_id: memberId,
|
||||
} as CreateTimeEntryBody;
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.createTimeEntry(newTimeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}),
|
||||
'Time entry created successfully',
|
||||
'Failed to create time entry'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
async function fetchMoreTimeEntries() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const latestTimeEntry = timeEntries.value[timeEntries.value.length - 1];
|
||||
dayjs(latestTimeEntry.start).utc().format('YYYY-MM-DD');
|
||||
|
||||
const timeEntriesResponse = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.getTimeEntries({
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
queries: {
|
||||
only_full_dates: 'true',
|
||||
member_id: getCurrentMembershipId(),
|
||||
end: dayjs(latestTimeEntry.start).utc().format(),
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
'Failed to fetch time entries'
|
||||
);
|
||||
if (timeEntriesResponse?.data && timeEntriesResponse.data.length > 0) {
|
||||
timeEntries.value = timeEntries.value.concat(timeEntriesResponse.data);
|
||||
} else {
|
||||
allTimeEntriesLoaded.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeEntry(timeEntryId: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTimeEntry(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
}),
|
||||
'Time entry deleted successfully',
|
||||
'Failed to delete time entry'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
async function updateTimeEntries(
|
||||
ids: string[],
|
||||
changes: UpdateMultipleTimeEntriesChangeset
|
||||
) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateMultipleTimeEntries(
|
||||
{
|
||||
ids: ids,
|
||||
changes: changes,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Time entries updated successfully',
|
||||
'Failed to update time entries'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const timeEntryIds = timeEntries.map((entry) => entry.id);
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTimeEntries(undefined, {
|
||||
queries: {
|
||||
ids: timeEntryIds,
|
||||
},
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}),
|
||||
'Time entries deleted successfully',
|
||||
'Failed to delete time entries'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
async function updateTimeEntry(timeEntry: TimeEntry) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
const response = await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.updateTimeEntry(timeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntry.id,
|
||||
},
|
||||
}),
|
||||
'Time entry updated successfully',
|
||||
'Failed to update time entry'
|
||||
);
|
||||
timeEntries.value = timeEntries.value.map((entry) =>
|
||||
entry.id === timeEntry.id ? response.data : entry
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntry'] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeEntries,
|
||||
fetchTimeEntries,
|
||||
updateTimeEntry,
|
||||
createTimeEntry,
|
||||
deleteTimeEntry,
|
||||
fetchMoreTimeEntries,
|
||||
allTimeEntriesLoaded,
|
||||
updateTimeEntries,
|
||||
deleteTimeEntries,
|
||||
patchTimeEntries,
|
||||
};
|
||||
});
|
||||
async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (organizationId && memberId !== undefined) {
|
||||
const newTimeEntry = {
|
||||
...timeEntry,
|
||||
member_id: memberId,
|
||||
} as CreateTimeEntryBody;
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.createTimeEntry(newTimeEntry, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}),
|
||||
'Time entry created successfully',
|
||||
'Failed to create time entry'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeEntry(timeEntryId: string) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTimeEntry(undefined, {
|
||||
params: {
|
||||
organization: organizationId,
|
||||
timeEntry: timeEntryId,
|
||||
},
|
||||
}),
|
||||
'Time entry deleted successfully',
|
||||
'Failed to delete time entry'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTimeEntries(timeEntries: TimeEntry[]) {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
const timeEntryIds = timeEntries.map((entry) => entry.id);
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.deleteTimeEntries(undefined, {
|
||||
queries: {
|
||||
ids: timeEntryIds,
|
||||
},
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}),
|
||||
'Time entries deleted successfully',
|
||||
'Failed to delete time entries'
|
||||
);
|
||||
await fetchTimeEntries();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeEntries,
|
||||
fetchTimeEntries,
|
||||
updateTimeEntry,
|
||||
createTimeEntry,
|
||||
deleteTimeEntry,
|
||||
fetchMoreTimeEntries,
|
||||
allTimeEntriesLoaded,
|
||||
updateTimeEntries,
|
||||
deleteTimeEntries,
|
||||
patchTimeEntries,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
import forms from '@tailwindcss/forms';
|
||||
import typography from '@tailwindcss/typography';
|
||||
import { solidtimeTheme } from './resources/js/packages/ui/tailwind.theme.js';
|
||||
|
||||
/** @type {import("tailwindcss").Config} */
|
||||
export default {
|
||||
@@ -16,130 +17,10 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
card: 'var(--theme-shadow-card)',
|
||||
dropdown: 'var(--theme-shadow-dropdown)',
|
||||
},
|
||||
containers: {
|
||||
'2xs': '16rem',
|
||||
},
|
||||
...solidtimeTheme,
|
||||
fontFamily: {
|
||||
sans: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
fontSize: {
|
||||
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
|
||||
base: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
lg: ['1rem', { lineHeight: '1.5rem' }],
|
||||
xl: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'3xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'4xl': ['1.75rem', { lineHeight: '2.25rem' }],
|
||||
'5xl': ['2rem', { lineHeight: '1' }],
|
||||
'6xl': ['2.25rem', { lineHeight: '1' }],
|
||||
'7xl': ['2.5rem', { lineHeight: '1' }],
|
||||
'8xl': ['3rem', { lineHeight: '1' }],
|
||||
'9xl': ['3.5rem', { lineHeight: '1' }],
|
||||
},
|
||||
colors: {
|
||||
ring: 'var(--ring)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
tertiary: 'var(--color-bg-tertiary)',
|
||||
quaternary: 'var(--color-bg-quaternary)',
|
||||
background: 'var(--background)',
|
||||
'text-primary': 'var(--color-text-primary)',
|
||||
'text-secondary': 'var(--color-text-secondary)',
|
||||
'text-tertiary': 'var(--color-text-tertiary)',
|
||||
'text-quaternary': 'var(--color-text-quaternary)',
|
||||
'border-primary': 'var(--color-border-primary)',
|
||||
'border-secondary': 'var(--color-border-secondary)',
|
||||
'border-tertiary': 'var(--color-border-tertiary)',
|
||||
'default-background': 'var(--theme-color-default-background)',
|
||||
'default-background-separator': 'var(--theme-color-default-background-separator)',
|
||||
'row-background': 'var(--theme-color-row-background)',
|
||||
'card-background': 'var(--theme-color-card-background)',
|
||||
'card-background-active': 'var(--theme-color-card-background-active)',
|
||||
'card-background-separator': 'var(--theme-color-card-background-separator)',
|
||||
'card-border': 'var(--theme-color-card-border)',
|
||||
'card-border-active': 'var(--theme-color-card-border-active)',
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
'tab-background': 'var(--theme-color-tab-background)',
|
||||
'tab-background-active': 'var(--theme-color-tab-background-active)',
|
||||
'tab-border': 'var(--theme-color-tab-border)',
|
||||
'icon-default': 'var(--theme-color-icon-default)',
|
||||
'icon-active': 'var(--theme-color-icon-active)',
|
||||
'menu-active': 'var(--theme-color-menu-active)',
|
||||
'input-border': 'var(--theme-color-input-border)',
|
||||
'input-border-active': 'var(--color-input-border-active)',
|
||||
'input-background': 'var(--theme-color-input-background)',
|
||||
'button-secondary-background': 'var(--theme-button-secondary-background)',
|
||||
'button-secondary-background-hover':
|
||||
'var(--theme-button-secondary-background-active)',
|
||||
'button-secondary-border': 'var(--theme-color-card-border)',
|
||||
'row-separator': 'var(--theme-color-row-separator-background)',
|
||||
'row-heading-background': 'var(--theme-color-row-heading-background)',
|
||||
'row-heading-border': 'var(--theme-color-row-heading-border)',
|
||||
accent: {
|
||||
'50': 'rgba(var(--color-accent-50), <alpha-value>)',
|
||||
'100': 'rgba(var(--color-accent-100), <alpha-value>)',
|
||||
'200': 'rgba(var(--color-accent-200), <alpha-value>)',
|
||||
'300': 'rgba(var(--color-accent-300), <alpha-value>)',
|
||||
'400': 'rgba(var(--color-accent-400), <alpha-value>)',
|
||||
'500': 'rgba(var(--color-accent-500), <alpha-value>)',
|
||||
'600': 'rgba(var(--color-accent-600), <alpha-value>)',
|
||||
'700': 'rgba(var(--color-accent-700), <alpha-value>)',
|
||||
'800': 'rgba(var(--color-accent-800), <alpha-value>)',
|
||||
'900': 'rgba(var(--color-accent-900), <alpha-value>)',
|
||||
'950': 'rgba(var(--color-accent-950), <alpha-value>)',
|
||||
DEFAULT: 'var(--color-accent-default)',
|
||||
foreground: 'var(--color-accent-foreground)',
|
||||
},
|
||||
'button-primary-background': 'var(--theme-color-button-primary-background)',
|
||||
'button-primary-background-hover':
|
||||
'var(--theme-color-button-primary-background-hover)',
|
||||
'button-primary-border': 'var(--theme-color-button-primary-border)',
|
||||
'button-primary-text': 'var(--theme-color-button-primary-text)',
|
||||
'input-select-active': 'var(--theme-color-input-select-active)',
|
||||
'input-select-active-hover': 'var(--theme-color-input-select-active-hover)',
|
||||
foreground: 'var(--foreground)',
|
||||
card: {
|
||||
DEFAULT: 'var(--card))',
|
||||
foreground: 'var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'var(--popover)',
|
||||
foreground: 'var(--popover-foreground)',
|
||||
border: 'var(--popover-border)',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'var(--destructive)',
|
||||
foreground: 'var(--destructive-foreground)',
|
||||
},
|
||||
border: 'var(--border)',
|
||||
input: 'var(--input)',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./resources/js/*"],
|
||||
"@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user