Bulk commit: November work

This commit is contained in:
2025-11-06 11:45:59 +01:00
parent 5446120d96
commit cca316daf2
61 changed files with 6581 additions and 259 deletions

View File

@ -3,6 +3,7 @@
import { BadgeCheck, Bell, ChevronsUpDown, LogOut } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useProfile } from '../queries'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
@ -19,6 +20,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
import { env } from '@/env'
export function NavUser({
user,
@ -30,6 +32,11 @@ export function NavUser({
}
}) {
const { isMobile } = useSidebar()
const { data } = useProfile()
if (!data) {
return <div>Loading...</div>
}
return (
<SidebarMenu>
@ -41,12 +48,12 @@ export function NavUser({
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={data.picture} alt={data.name} />
<AvatarFallback className="rounded-lg">KH</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">{data.name}</span>
<span className="truncate text-xs">{data.Email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
@ -60,30 +67,26 @@ export function NavUser({
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarImage src={data.picture} alt={data.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">{data.name}</span>
<span className="truncate text-xs">{data.Email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* <DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator /> */}
<DropdownMenuGroup>
<Link to="/about">
<a
href="https://keycloak.kocoder.xyz/realms/che/account"
target="_blank"
>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
</Link>
</a>
{/* <DropdownMenuItem>
<CreditCard />
Billing
@ -96,7 +99,7 @@ export function NavUser({
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<a href="http://localhost:3000/api/logout">
<a href={`${env.VITE_BACKEND_URI}/api/auth/logout`}>
<DropdownMenuItem>
<LogOut />
Log out

View File

@ -1,4 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { env } from '@/env'
const sessionKeys = {
all: ['sessions'] as const,
@ -11,12 +13,23 @@ export type Session = {
CreatedAt: Date
}
export type User = {
ID: number
CreatedAt: Date
UpdatedAt: Date
DeletedAt: Date | undefined
sub: string
Email: string
name: string
picture: string
}
export function useCurrentSession() {
return useQuery<Session>({
queryKey: sessionKeys.current(),
queryFn: async () => {
const data = await fetch(
'http://localhost:3000/api/auth/currentSession',
env.VITE_BACKEND_URI + '/api/auth/currentSession',
{
credentials: 'include',
},
@ -25,3 +38,22 @@ export function useCurrentSession() {
},
})
}
export function useProfile() {
const queryClient = useQueryClient()
const navigate = useNavigate()
return useQuery<User>({
queryKey: sessionKeys.current(),
queryFn: async () => {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/users/current', {
credentials: 'include',
})
if (data.status == 401) {
queryClient.invalidateQueries()
navigate({ to: '/' })
}
return await data.json()
},
})
}

View File

@ -4,6 +4,7 @@ import {
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'
import { env } from '@/env'
const ansprechpartnerKeys = {
all: ['ansprechpartner'] as const,
@ -35,9 +36,12 @@ export function useAllAnsprechpartners() {
return useQuery<Ansprechpartner>({
queryKey: ansprechpartnerKeys.lists(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/ansprechpartner/all', {
credentials: 'include',
})
const data = await fetch(
env.VITE_BACKEND_URI + '/v1/ansprechpartner/all',
{
credentials: 'include',
},
)
return await data.json()
},
})
@ -48,7 +52,7 @@ export function useAnsprechpartner(id: number) {
queryKey: ansprechpartnerKeys.detail(id),
queryFn: async () => {
const data = await fetch(
'http://localhost:3000/v1/ansprechpartner/' + id,
env.VITE_BACKEND_URI + '/v1/ansprechpartner/' + id,
{ credentials: 'include' },
)
return await data.json()
@ -62,7 +66,7 @@ export function useAnsprechpartnerEditMutation() {
return useMutation({
mutationFn: async (ansprechpartner: Ansprechpartner) => {
await fetch(
'http://localhost:3000/v1/ansprechpartner/' + ansprechpartner.ID,
env.VITE_BACKEND_URI + '/v1/ansprechpartner/' + ansprechpartner.ID,
{
headers: {
'content-type': 'application/json',

View File

@ -0,0 +1,40 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { EditorContent, useEditor } from '@tiptap/react'
import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import { Bold, Italic, Underline } from 'lucide-react'
const Tiptap = () => {
const editor = useEditor({
extensions: [StarterKit], // define your extension array
content: '<p>Hello World!</p>', // initial content
immediatelyRender: false,
})
if (!editor) return <></>
return (
<>
<EditorContent editor={editor} />
<FloatingMenu editor={editor}>This is the floating menu</FloatingMenu>
<BubbleMenu editor={editor}>
<ToggleGroup variant="outline" type="multiple">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<Bold className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<Italic className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem
value="strikethrough"
aria-label="Toggle strikethrough"
>
<Underline className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</BubbleMenu>
</>
)
}
export default Tiptap

View File

@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
function Kanban({ children }: { children: ReactNode }) {
return (
<div className="box-border flex gap-2 h-full min-w-full max-w-screen overflow-x-auto snap-x snap-mandatory md:snap-none">
{children}
</div>
)
}
export default Kanban

View File

@ -0,0 +1,15 @@
type Card = {
name: string
path: string
description: string
labels: Array<{
name: string
className: string
}>
}
function KanbanCard({ card }: { card: Card }) {
return <div className="bg-background rounded p-2">{card.name}</div>
}
export default KanbanCard

View File

@ -0,0 +1,59 @@
import { GripVertical, Tickets } from 'lucide-react'
import { useRef } from 'react'
import type { ReactNode } from 'react'
function KanbanColumn({
children,
name,
itemCount,
}: {
children: ReactNode
name: string
itemCount: number
}) {
const column = useRef<HTMLDivElement>(null)
const handleDraggableStart = () => {
column.current?.setAttribute('draggable', 'true')
}
const handleDraggableStop = () => {
column.current?.setAttribute('draggable', 'false')
}
const handleDragStart = (e: DragEvent) => {
e.dataTransfer?.setData('text/custom', 'ASDF')
}
const handleDragStop = () => {
column.current?.setAttribute('draggable', 'false')
}
return (
<div
className="min-w-96 bg-accent rounded-md p-4 snap-center"
ref={column}
onDragStart={handleDragStart}
onDragEnd={handleDragStop}
>
<div className="flex justify-between mb-2">
<div className="flex place-items-center">
<GripVertical
size={20}
className="mr-2 cursor-grab"
onMouseDown={handleDraggableStart}
onMouseUp={handleDraggableStop}
/>
<p>{name}</p>
</div>
<div className="flex place-items-center gap-2">
{itemCount}
<Tickets size={20} />
</div>
</div>
{children}
</div>
)
}
export default KanbanColumn

View File

@ -0,0 +1,28 @@
import { useRef } from 'react'
function KanbanDropzone() {
const ref = useRef<HTMLDivElement>(null)
const handleDragEnter = () => {
console.log('DRAGOVER')
if (!ref.current) return
ref.current.style.backgroundColor = '#41b2b2'
}
const handleDragLeave = () => {
console.log('DRAGOVER')
if (!ref.current) return
ref.current.style.backgroundColor = 'transparent'
}
return (
<div
className="min-w-1 h-full block"
ref={ref}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
></div>
)
}
export default KanbanDropzone

View File

@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { env } from '@/env'
const mandantKeys = {
all: ['mandant'] as const,
@ -18,7 +19,7 @@ export function useCurrentMandant() {
return useQuery<Mandant>({
queryKey: mandantKeys.current(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/mandant/current', {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/current', {
credentials: 'include',
})
return await data.json()
@ -30,7 +31,7 @@ export function useAllMandanten() {
return useQuery<Array<Mandant>>({
queryKey: mandantKeys.lists(),
queryFn: async () => {
const data = await fetch('http://localhost:3000/v1/mandant/all', {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/all', {
credentials: 'include',
})
return await data.json()
@ -43,7 +44,7 @@ export function useCurrentMandantMutation() {
return useMutation({
mutationFn: async (mandant: Mandant) => {
const res = await fetch('http://localhost:3000/v1/mandant/current', {
const res = await fetch(env.VITE_BACKEND_URI + '/v1/mandant/current', {
headers: {
'content-type': 'application/json',
},

View File

@ -0,0 +1,82 @@
import { Folder, MoreHorizontal, Share, Trash2 } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { useAllProjects } from '../queries'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'
export function NavProjects() {
const { isMobile } = useSidebar()
const { data: projects } = useAllProjects({
fetchSize: 5,
sorting: [],
})
if (!projects) {
return <p>Loading...</p>
}
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
{projects.pages[0].data.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<Link to="/projects/view/$id" params={{ id: item.ID.toString() }}>
{item.icon}
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontal />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48"
side={isMobile ? 'bottom' : 'right'}
align={isMobile ? 'end' : 'start'}
>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Project</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Share className="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton>
<MoreHorizontal />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@ -0,0 +1,196 @@
import {
CircleCheckIcon,
CircleUserIcon,
ClipboardCheckIcon,
MoreHorizontalIcon,
ReceiptEuroIcon,
SpeakerIcon,
} from 'lucide-react'
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useAllProjects } from '../queries'
import type {
ColumnDef,
RowSelectionState,
SortingState,
VisibilityState,
} from '@tanstack/react-table'
import type { PaginatedProject } from '../queries'
import { Checkbox } from '@/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table-column-header'
import { DataTable } from '@/components/data-table'
const iconSize = 16
export const columnDefs: Array<ColumnDef<PaginatedProject>> = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="aspect-square"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 26,
},
{
accessorKey: 'name',
header: ({ column, header }) => {
return (
<DataTableColumnHeader
column={column}
title="Name"
style={{ width: header.getSize() }}
/>
)
},
size: 200,
},
{
accessorKey: 'description',
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Beschreibung" />
},
size: 400,
},
{
accessorKey: 'progress',
header: 'Project progress',
cell: () => {
return (
<div className="flex flex-row gap-2 items-center h-4">
<SpeakerIcon size={iconSize} />
<CircleUserIcon size={iconSize} />
<CircleCheckIcon size={iconSize} />
<ReceiptEuroIcon size={iconSize} />
<ClipboardCheckIcon size={iconSize} />
</div>
)
},
size: 200,
},
{
accessorKey: 'client',
header: 'Kunde',
size: 200,
},
{
accessorKey: 'location',
header: 'Location',
size: 200,
},
{
accessorKey: 'startdate',
header: 'Planungszeitraum Start',
size: 200,
},
{
accessorKey: 'enddate',
header: 'Planungszeitraum Ende',
size: 200,
},
{
accessorKey: 'type',
header: 'Projekttyp',
size: 200,
},
{
id: 'actions',
cell: ({ row }) => {
const project = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() =>
navigator.clipboard.writeText(project.ID.toString())
}
>
Copy project ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="/projects/view/$id"
params={{ id: project.ID.toString() }}
>
View Project
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 50,
},
]
const fetchSize = 25
function ProjectsTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
description: false,
client: false,
location: false,
type: false,
enddate: false,
startdate: false,
})
const [selected, setSelected] = useState<RowSelectionState>({})
const { data, fetchNextPage, isFetching, isLoading } = useAllProjects({
fetchSize,
sorting,
})
return (
<DataTable
columns={columnDefs}
data={data}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
isLoading={isLoading}
setSorting={setSorting}
sorting={sorting}
rowSelection={selected}
setRowSelection={setSelected}
/>
)
}
export default ProjectsTable

View File

@ -0,0 +1,119 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { env } from '@/env'
const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
getAll: (fetchSize: number, sorting: any) =>
[...projectKeys.lists(), 'all', fetchSize, sorting] as const,
get: (id: number) => [...projectKeys.all, 'get', id] as const,
}
export type PaginatedProject = {
data: Array<Project>
meta: {
totalProjectsCount: number
}
}
export type Project = {
ID: number
name: string
description: string
icon: string
MandantID: number
}
export function getProjectQueryObject(id: number) {
return {
queryKey: projectKeys.get(id),
queryFn: async () => {
const data = await fetch(env.VITE_BACKEND_URI + '/v1/projects/' + id, {
credentials: 'include',
})
return await data.json()
},
}
}
export function useProject(id: number) {
return useQuery<Project>(getProjectQueryObject(id))
}
export function useAllProjects({
fetchSize,
sorting,
}: {
fetchSize: number
sorting: any
}) {
return useInfiniteQuery<PaginatedProject>({
queryKey: projectKeys.getAll(fetchSize, sorting),
queryFn: async ({ pageParam = 0 }) => {
const start = (pageParam as number) * fetchSize
const data = await fetch(
env.VITE_BACKEND_URI +
'/v1/projects/all?' +
new URLSearchParams(sorting[0]),
{
credentials: 'include',
headers: {
'X-OFFSET': start.toString(),
'X-PER-PAGE': fetchSize.toString(),
},
},
)
return await data.json()
},
initialPageParam: 0,
getNextPageParam: (_lastGroup, groups) => groups.length,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
})
}
export function useProjectEdit(id: number) {
return useMutation({
mutationFn: async (project: Project) => {
await fetch(env.VITE_BACKEND_URI + '/v1/projects/' + id + '/edit', {
headers: {
'content-type': 'application/json',
},
method: 'POST',
body: JSON.stringify(project),
credentials: 'include',
})
},
})
}
export function useProjectCreate() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (project: Project) => {
const res = await fetch(env.VITE_BACKEND_URI + '/v1/projects/new', {
headers: {
'content-type': 'application/json',
},
method: 'POST',
body: JSON.stringify(project),
credentials: 'include',
})
const newCurrentMandant = await res.json()
queryClient.invalidateQueries({ queryKey: projectKeys.lists() })
queryClient.setQueryData(
projectKeys.get(newCurrentMandant.id),
(_: Project) => newCurrentMandant,
)
},
})
}