diff --git a/apps/client/package.json b/apps/client/package.json index c1592ce..52bc207 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -32,11 +32,13 @@ "framer-motion": "^11.11.17", "lexorank": "^1.0.5", "lucide-react": "^0.454.0", + "next-themes": "^0.4.3", "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", "socket.io-client": "^4.8.1", + "sonner": "^1.7.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8" diff --git a/apps/client/src/components/ui/date-range-picker.tsx b/apps/client/src/components/ui/date-range-picker.tsx index 6b8b3d0..7c0e46e 100644 --- a/apps/client/src/components/ui/date-range-picker.tsx +++ b/apps/client/src/components/ui/date-range-picker.tsx @@ -58,7 +58,7 @@ export function DateRangePicker({ className, date, onChange }: DateRangePickerPr + + + + ); +} diff --git a/apps/client/src/features/project/sprint/components/CreateSrpint.tsx b/apps/client/src/features/project/sprint/components/CreateSrpint.tsx deleted file mode 100644 index 3d23597..0000000 --- a/apps/client/src/features/project/sprint/components/CreateSrpint.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm, Controller } from 'react-hook-form'; -import { Plus } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { DateRangePicker } from '@/components/ui/date-range-picker'; -import { sprintFormSchema, SprintFormValues } from '@/features/project/sprint/sprintSchema.ts'; - -interface CreateProjectSprintProps { - onCreate: (data: SprintFormValues) => void; -} - -export function CreateSrpint({ onCreate }: CreateProjectSprintProps) { - const createForm = useForm({ - resolver: zodResolver(sprintFormSchema), - defaultValues: { - name: '', - dateRange: { - from: new Date(), - to: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), // Default to 1 week - }, - }, - }); - - const handleSubmit = (data: SprintFormValues) => { - onCreate(data); - createForm.reset({ - name: '', - dateRange: { - from: new Date(), - to: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), - }, - }); - }; - - return ( - - - Create Sprint - Add a new sprint to your project. - - -
-
-
- - {createForm.formState.errors.name && ( -

- {createForm.formState.errors.name.message} -

- )} -
-
- -
-
- -
-
-
- ); -} diff --git a/apps/client/src/features/project/sprint/components/SprintList.tsx b/apps/client/src/features/project/sprint/components/SprintList.tsx index 6ebf18d..d81f5a6 100644 --- a/apps/client/src/features/project/sprint/components/SprintList.tsx +++ b/apps/client/src/features/project/sprint/components/SprintList.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Pencil, X } from 'lucide-react'; +import { UseMutationResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -10,24 +12,47 @@ import { DateRangePicker } from '@/components/ui/date-range-picker'; import { sprintFormSchema, SprintFormValues } from '@/features/project/sprint/sprintSchema.ts'; import { getStatusColor } from '@/features/project/sprint/getStatusColor.ts'; import { getSprintStatus } from '@/features/project/sprint/getSprintStatus.ts'; -import { Sprint } from '@/features/types.ts'; +import { BaseResponse, Sprint } from '@/features/types.ts'; + +import { UpdateSprintDto } from '@/features/project/types.ts'; +import { useToast } from '@/lib/useToast.tsx'; interface ProjectSprintManagerProps { sprints: Sprint[]; - onUpdate: (sprintId: number, data: SprintFormValues) => void; - onDelete: (sprintId: number) => void; + updateMutation: UseMutationResult< + BaseResponse, + AxiosError, + { + sprintId: number; + updateSprintDto: UpdateSprintDto; + } + >; + deleteMutation: UseMutationResult; } -export function SprintList({ sprints, onUpdate, onDelete }: ProjectSprintManagerProps) { +export function SprintList({ sprints, updateMutation, deleteMutation }: ProjectSprintManagerProps) { + const toast = useToast(); + const [editingId, setEditingId] = useState(null); - const editForm = useForm({ + const { mutate: updateSprint } = updateMutation; + + const { mutate: deleteSprint } = deleteMutation; + + const { + handleSubmit, + register, + setError, + control, + reset, + formState: { errors }, + } = useForm({ resolver: zodResolver(sprintFormSchema), }); const startEditing = (sprint: Sprint) => { setEditingId(sprint.id); - editForm.reset({ + reset({ name: sprint.name, dateRange: { from: new Date(sprint.startDate), @@ -36,6 +61,48 @@ export function SprintList({ sprints, onUpdate, onDelete }: ProjectSprintManager }); }; + const dateToYYYYMMDD = (date: Date) => date.toISOString().split('T')[0]; + + const onUpdate = (sprintId: number, data: SprintFormValues) => { + updateSprint( + { + sprintId, + updateSprintDto: { + name: data.name, + startDate: dateToYYYYMMDD(data.dateRange.from), + endDate: dateToYYYYMMDD(data.dateRange.to), + }, + }, + { onSuccess: onUpdateSuccess, onError: onUpdateError } + ); + }; + + const onDelete = (sprintId: number) => { + deleteSprint(sprintId, { onSuccess: onDeleteSuccess, onError: onDeleteError }); + }; + + const onUpdateSuccess = () => { + toast.success('Sprint updated successfully'); + setEditingId(null); + }; + + const onUpdateError = (error: AxiosError) => { + if (error.response?.status === 409) { + setError('name', { message: 'Sprint with this name already exists' }); + return; + } + + toast.error('Failed to update sprint'); + }; + + const onDeleteSuccess = () => { + toast.success('Sprint deleted successfully'); + }; + + const onDeleteError = () => { + toast.error('Failed to delete sprint'); + }; + return ( @@ -52,17 +119,27 @@ export function SprintList({ sprints, onUpdate, onDelete }: ProjectSprintManager {editingId === sprint.id ? (
{ + onSubmit={handleSubmit((data) => { onUpdate(sprint.id, data); - setEditingId(null); })} > -
- +
+ + {errors.name && ( +

{errors.name.message}

+ )}
-
+
( diff --git a/apps/client/src/features/project/sprint/useSprintMutations.ts b/apps/client/src/features/project/sprint/useSprintMutations.ts index a8c39b4..d19d7f5 100644 --- a/apps/client/src/features/project/sprint/useSprintMutations.ts +++ b/apps/client/src/features/project/sprint/useSprintMutations.ts @@ -1,6 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; import { projectAPI } from '@/features/project/api.ts'; import { CreateSprintDto, UpdateSprintDto } from '@/features/project/types.ts'; +import { BaseResponse } from '@/features/types.ts'; export const useSprintMutations = (projectId: number) => { const queryClient = useQueryClient(); @@ -12,25 +14,26 @@ export const useSprintMutations = (projectId: number) => { }; return { - create: useMutation({ - mutationFn: (createSprintDto: CreateSprintDto) => - projectAPI.createSprint(projectId, createSprintDto), + create: useMutation({ + mutationFn: (createSprintDto) => projectAPI.createSprint(projectId, createSprintDto), onSuccess: invalidateSprints, }), - update: useMutation({ - mutationFn: ({ - sprintId, - updateSprintDto, - }: { + update: useMutation< + BaseResponse, + AxiosError, + { sprintId: number; updateSprintDto: UpdateSprintDto; - }) => projectAPI.updateSprint(sprintId, updateSprintDto), + } + >({ + mutationFn: ({ sprintId, updateSprintDto }) => + projectAPI.updateSprint(sprintId, updateSprintDto), onSuccess: invalidateSprints, }), - delete: useMutation({ - mutationFn: (sprintId: number) => projectAPI.deleteSprint(sprintId), + delete: useMutation({ + mutationFn: (sprintId) => projectAPI.deleteSprint(sprintId), onSuccess: invalidateSprints, }), }; diff --git a/apps/client/src/lib/useToast.tsx b/apps/client/src/lib/useToast.tsx new file mode 100644 index 0000000..a1a4f0e --- /dev/null +++ b/apps/client/src/lib/useToast.tsx @@ -0,0 +1,35 @@ +import { toast } from 'sonner'; +import { cn } from '@/lib/utils.ts'; + +type ToastType = 'success' | 'error' | 'info'; + +interface ToastOptions { + message: string; + type?: ToastType; + duration?: number; +} + +export const useToast = () => { + const showToast = ({ message, type = 'info', duration = 2000 }: ToastOptions) => { + const styles = { + success: 'bg-green-500 hover:bg-green-600', + error: 'bg-red-500 hover:bg-red-600', + info: 'bg-gray-200 hover:bg-slate-300', + }; + + toast(message, { + duration, + className: cn( + 'text-white py-3 px-4 rounded-md shadow-lg font-medium text-sm border-transparent', + styles[type] + ), + }); + }; + + return { + success: (message: string, duration?: number) => + showToast({ message, type: 'success', duration }), + error: (message: string, duration?: number) => showToast({ message, type: 'error', duration }), + info: (message: string, duration?: number) => showToast({ message, type: 'info', duration }), + }; +}; diff --git a/apps/client/src/pages/LabelsSettings.tsx b/apps/client/src/pages/LabelsSettings.tsx index b486474..1c4681a 100644 --- a/apps/client/src/pages/LabelsSettings.tsx +++ b/apps/client/src/pages/LabelsSettings.tsx @@ -3,11 +3,12 @@ import { useLabelsQuery } from '@/features/project/label/useLabelsQuery.ts'; import { useLabelMutations } from '@/features/project/label/useLabelMutations.ts'; import { LabelList } from '@/features/project/label/components/LabelList.tsx'; import { CreateLabel } from '@/features/project/label/components/CreateLabel.tsx'; -import { LabelFormValues } from '@/features/project/label/labelSchema.ts'; export default function LabelsSettings() { const { projectId } = useLoaderData({ from: '/_auth/$project/settings/labels' }); + const { data: labels = [] } = useLabelsQuery(projectId); + const { create: createMutation, update: updateMutation, @@ -16,18 +17,8 @@ export default function LabelsSettings() { return (
- - updateMutation.mutate({ - labelId, - updateLabelDto: data, - }) - } - onDelete={(labelId) => deleteMutation.mutate(labelId)} - /> - - createMutation.mutate(data)} /> + +
); } diff --git a/apps/client/src/pages/SprintsSettings.tsx b/apps/client/src/pages/SprintsSettings.tsx index 9a9534d..551c5a9 100644 --- a/apps/client/src/pages/SprintsSettings.tsx +++ b/apps/client/src/pages/SprintsSettings.tsx @@ -2,8 +2,7 @@ import { useLoaderData } from '@tanstack/react-router'; import { useSprintsQuery } from '@/features/project/sprint/useSprintsQuery'; import { useSprintMutations } from '@/features/project/sprint/useSprintMutations'; import { SprintList } from '@/features/project/sprint/components/SprintList.tsx'; -import { CreateSrpint } from '@/features/project/sprint/components/CreateSrpint.tsx'; -import { SprintFormValues } from '@/features/project/sprint/sprintSchema.ts'; +import { CreateSprint } from '@/features/project/sprint/components/CreateSprint.tsx'; export default function SprintsSettings() { const { projectId } = useLoaderData({ from: '/_auth/$project/settings/sprints' }); @@ -14,33 +13,14 @@ export default function SprintsSettings() { delete: deleteMutation, } = useSprintMutations(projectId); - const dateToYYYYMMDD = (date: Date) => date.toISOString().split('T')[0]; - return (
- updateMutation.mutate({ - sprintId, - updateSprintDto: { - name: data.name, - startDate: dateToYYYYMMDD(data.dateRange.from), - endDate: dateToYYYYMMDD(data.dateRange.to), - }, - }) - } - onDelete={(sprintId) => deleteMutation.mutate(sprintId)} - /> - - createMutation.mutate({ - name: data.name, - startDate: dateToYYYYMMDD(data.dateRange.from), - endDate: dateToYYYYMMDD(data.dateRange.to), - }) - } + updateMutation={updateMutation} + deleteMutation={deleteMutation} /> +
); } diff --git a/apps/client/src/routes/_auth.$project.settings.tsx b/apps/client/src/routes/_auth.$project.settings.tsx index 8464305..0fcd752 100644 --- a/apps/client/src/routes/_auth.$project.settings.tsx +++ b/apps/client/src/routes/_auth.$project.settings.tsx @@ -17,14 +17,14 @@ function ProjectSettingsLayout() { return (
-
+

Settings

-
+
diff --git a/apps/client/src/routes/_auth.tsx b/apps/client/src/routes/_auth.tsx index c4ed4ca..214ae0e 100644 --- a/apps/client/src/routes/_auth.tsx +++ b/apps/client/src/routes/_auth.tsx @@ -1,6 +1,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { Link, Outlet, createFileRoute, redirect, useParams } from '@tanstack/react-router'; import { ChevronsUpDownIcon, LogOut } from 'lucide-react'; +import { SlashIcon } from '@radix-ui/react-icons'; import { Harmony } from '@/components/logo'; import { Topbar } from '@/components/navigation/topbar'; import { @@ -11,6 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; import { axiosInstance } from '@/lib/axios.ts'; import { useAuth } from '@/features/auth/useAuth.ts'; +import { Toaster } from '@/components/ui/sonner.tsx'; type Project = { id: number; @@ -76,6 +78,8 @@ function AuthLayout() { }, 100); }; + const currentProject = projects.find((project) => project.id === Number(params.project)); + return (
+
-

{params.project ?? 'My Account'}

+

{(params.project && currentProject?.title) ?? 'My Account'}

@@ -132,6 +137,7 @@ function AuthLayout() { } /> +
); }