From f8e05dc4a62175685f2162943f57d875f1d67c33 Mon Sep 17 00:00:00 2001 From: Urban Modig Date: Wed, 15 Oct 2025 23:07:23 +0200 Subject: [PATCH] feat(tasks): skapa/redigera task med RHF+zod, POST/PATCH, och query-invalidation --- src/app/router.tsx | 93 +++++-- src/auth/RequireAuth.tsx | 32 +-- .../tasks/components/DraggableTaskCard.tsx | 10 +- .../tasks/components/DroppableColumn.tsx | 4 +- src/features/tasks/components/TaskCard.tsx | 16 +- .../tasks/components/TaskFormModal.tsx | 237 ++++++++++++++++++ src/features/tasks/mutations.ts | 123 +++++++-- src/features/tasks/schemas.ts | 26 ++ src/pages/ProjectBoardPage.tsx | 97 ++++++- vite.config.ts | 33 ++- 10 files changed, 592 insertions(+), 79 deletions(-) create mode 100644 src/features/tasks/components/TaskFormModal.tsx create mode 100644 src/features/tasks/schemas.ts diff --git a/src/app/router.tsx b/src/app/router.tsx index 7ffbbeb..1523a63 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,38 +1,85 @@ -// src/app/router.tsx import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { Suspense, lazy } from 'react' import { RootLayout } from '@/components/layout/RootLayout' -import { DashboardPage } from '@/pages/DashboardPage' -import { HouseholdBoardPage } from '@/pages/HouseholdBoardPage' -import { ProjectBoardPage } from '@/pages/ProjectBoardPage' -import { DueTomorrowPage } from '@/pages/DueTomorrowPage' -import AuthCallbackPage from '@/pages/AuthCallbackPage' -import SilentRenewPage from '@/pages/SilentRenewPage' -import LogoutPage from '@/pages/LogoutPage' import { RequireAuth } from '@/auth/RequireAuth' +// Hjälpare: mappa namngiven export → default för React.lazy +const load = (importer: () => Promise, key: K) => + lazy(() => importer().then((m) => ({ default: m[key] as unknown as React.ComponentType }))) + +// Dina sidkomponenter (named exports i respektive fil) +const DashboardPage = load(() => import('@/pages/DashboardPage'), 'DashboardPage') +const HouseholdBoardPage = load(() => import('@/pages/HouseholdBoardPage'), 'HouseholdBoardPage') +const ProjectBoardPage = load(() => import('@/pages/ProjectBoardPage'), 'ProjectBoardPage') +const DueTomorrowPage = load(() => import('@/pages/DueTomorrowPage'), 'DueTomorrowPage') + +// OIDC-sidor (named eller default – anpassa 'key' om din fil exporterar annat) +const AuthCallbackPage = load(() => import('@/pages/AuthCallbackPage'), 'default') // export default i vår version +const SilentRenewPage = load(() => import('@/pages/SilentRenewPage'), 'default') // om du har en sådan + +function Fallback() { + return
Laddar…
+} + const router = createBrowserRouter([ - { path: '/auth/callback', element: }, - { path: '/auth/silent-renew', element: }, - { path: '/logout', element: }, + // 🔓 Publika OIDC-rutter – måste vara o-skyddade + { path: '/auth/callback', element: ( + }> + + + ) + }, + { path: '/auth/silent-renew', element: ( + }> + + + ) + }, + // 🔒 Allt annat kräver inloggning { path: '/', element: ( - - - - ), + + + + ), children: [ - { index: true, element: }, - { path: 'households/:householdId/board', element: }, - { path: 'projects/:projectId/board', element: }, - { path: 'tasks/due-tomorrow', element: }, + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'households/:householdId/board', + element: ( + }> + + + ), + }, + { + path: 'projects/:projectId/board', + element: ( + }> + + + ), + }, + { + path: 'tasks/due-tomorrow', + element: ( + }> + + + ), + }, ], }, -],{ - basename: (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''), // t.ex. "/hemhub" -}) +]) export function AppRouter() { return } - diff --git a/src/auth/RequireAuth.tsx b/src/auth/RequireAuth.tsx index 6394a3d..8eeb86d 100644 --- a/src/auth/RequireAuth.tsx +++ b/src/auth/RequireAuth.tsx @@ -1,19 +1,21 @@ -import type { PropsWithChildren } from 'react' -import { useEffect } from 'react' -import { useLocation } from 'react-router-dom' -import { useAuth } from './useAuth' - +import { type PropsWithChildren, useEffect } from 'react' +import { useAuth } from '@/auth/useAuth' +/** + * Hårdare skydd: om ej inloggad → trigga signIn och rendera inget. + * När auth är klar kommer användaren tillbaka via /auth/callback. + */ export function RequireAuth({ children }: PropsWithChildren) { -const { isAuthenticated, signIn } = useAuth() -const loc = useLocation() + const { isAuthenticated, signIn } = useAuth() + useEffect(() => { + if (!isAuthenticated) { + // Spara nuvarande URL så vi kan komma tillbaka efter login + const returnTo = window.location.pathname + window.location.search + void signIn(returnTo) + } + }, [isAuthenticated, signIn]) -useEffect(() => { -if (!isAuthenticated) void signIn(loc.pathname + loc.search) -}, [isAuthenticated, signIn, loc]) - - -if (!isAuthenticated) return null -return <>{children} -} \ No newline at end of file + if (!isAuthenticated) return null + return <>{children} +} diff --git a/src/features/tasks/components/DraggableTaskCard.tsx b/src/features/tasks/components/DraggableTaskCard.tsx index 3bc4032..e32df77 100644 --- a/src/features/tasks/components/DraggableTaskCard.tsx +++ b/src/features/tasks/components/DraggableTaskCard.tsx @@ -2,11 +2,17 @@ import { useDraggable } from '@dnd-kit/core' import type { Task } from '@/types/task' import { TaskCard } from './TaskCard' -export function DraggableTaskCard({ task }: { task: Task }) { +export function DraggableTaskCard({ + task, + onClick, +}: { + task: Task + onClick?: (id: string) => void +}) { const { attributes, listeners, setNodeRef } = useDraggable({ id: task.id }) return (
- +
) } diff --git a/src/features/tasks/components/DroppableColumn.tsx b/src/features/tasks/components/DroppableColumn.tsx index eae0b6f..cc6f9dd 100644 --- a/src/features/tasks/components/DroppableColumn.tsx +++ b/src/features/tasks/components/DroppableColumn.tsx @@ -6,10 +6,12 @@ export function DroppableColumn({ title, status, items, + onCardClick, }: { title: string status: TaskStatus items: Task[] + onCardClick?: (id: string) => void }) { const { isOver, setNodeRef } = useDroppable({ id: status }) const empty = items.length === 0 @@ -42,7 +44,7 @@ export function DroppableColumn({ Släpp här för att flytta till {title} ) : ( - items.map((t) => ) + items.map((t) => ) )} diff --git a/src/features/tasks/components/TaskCard.tsx b/src/features/tasks/components/TaskCard.tsx index 440044f..cb32d45 100644 --- a/src/features/tasks/components/TaskCard.tsx +++ b/src/features/tasks/components/TaskCard.tsx @@ -1,11 +1,17 @@ import type { Task } from '@/types/task' -export function TaskCard({ task }: { task: Task }) { +type Props = { + task: Task + onClick?: (taskId: string) => void +} + +export function TaskCard({ task, onClick }: Props) { return ( -
@@ -22,6 +28,6 @@ export function TaskCard({ task }: { task: Task }) { {task.assigneeName && (
Assignee: {task.assigneeName}
)} -
+ ) } diff --git a/src/features/tasks/components/TaskFormModal.tsx b/src/features/tasks/components/TaskFormModal.tsx new file mode 100644 index 0000000..375f2da --- /dev/null +++ b/src/features/tasks/components/TaskFormModal.tsx @@ -0,0 +1,237 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + TaskCreateSchema, + TaskUpdateSchema, + TaskPriorityEnum, + type TaskCreateInput, + type TaskUpdateInput, +} from '@/features/tasks/schemas' + +/** Skapa-variant */ +export function CreateTaskFormModal(props: { + open: boolean + onOpenChange: (v: boolean) => void + defaultValues: Omit & { projectId: string } + onSubmit: (values: TaskCreateInput) => void +}) { + const { open, onOpenChange, defaultValues, onSubmit } = props + + const form = useForm({ + resolver: zodResolver(TaskCreateSchema), + defaultValues, + mode: 'onChange', + }) + + useEffect(() => { + form.reset(defaultValues) + }, [defaultValues, form]) + + const submit = form.handleSubmit((values) => { + onSubmit({ ...values, projectId: defaultValues.projectId }) + onOpenChange(false) + }) + + return ( + +
+ + (v === '' ? null : v) })} + /> +
+ (v === '' ? null : v) })} + /> + (v === '' ? null : v) })} + options={[ + { value: '', label: '—' }, + ...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })), + ]} + /> +
+ onOpenChange(false)} submitDisabled={!form.formState.isValid} /> + +
+ ) +} + +/** Redigera-variant */ +export function EditTaskFormModal(props: { + open: boolean + onOpenChange: (v: boolean) => void + taskId: string + defaultValues: TaskUpdateInput + onSubmit: (values: TaskUpdateInput) => void +}) { + const { open, onOpenChange, defaultValues, onSubmit } = props + + const form = useForm({ + resolver: zodResolver(TaskUpdateSchema), + defaultValues, + mode: 'onChange', + }) + + useEffect(() => { + form.reset(defaultValues) + }, [defaultValues, form]) + + const submit = form.handleSubmit((values) => { + onSubmit(values) + onOpenChange(false) + }) + + return ( + +
+ + (v === '' ? null : v) })} + /> +
+ (v === '' ? null : v) })} + /> + (v === '' ? null : v) })} + options={[ + { value: '', label: '—' }, + ...TaskPriorityEnum.options.map((p) => ({ value: p, label: p })), + ]} + /> + +
+ onOpenChange(false)} submitDisabled={!form.formState.isValid} /> + +
+ ) +} + +/* ----------------- UI-helpers ----------------- */ + +function DialogShell({ + title, + open, + onOpenChange, + children, +}: { + title: string + open: boolean + onOpenChange: (v: boolean) => void + children: React.ReactNode +}) { + return ( +
+
onOpenChange(false)} /> +
+
+
+

{title}

+
+ {children} +
+
+
+ ) +} + +type FieldProps = { + label: string + error?: string +} & React.InputHTMLAttributes + +function FieldText(props: FieldProps) { + const { label, error, ...rest } = props + return ( +
+ + + {error &&

{error}

} +
+ ) +} + +type TextareaProps = { + label: string + error?: string +} & React.TextareaHTMLAttributes + +function FieldTextarea(props: TextareaProps) { + const { label, error, ...rest } = props + return ( +
+ +