diff --git a/frontend/app/(dashboard)/reports/page.tsx b/frontend/app/(dashboard)/reports/page.tsx new file mode 100644 index 0000000..108334d --- /dev/null +++ b/frontend/app/(dashboard)/reports/page.tsx @@ -0,0 +1,257 @@ +// frontend/app/(dashboard)/reports/page.tsx +"use client"; + +import Link from "next/link"; +import { format } from "date-fns"; +import { BarChart3, Package } from "lucide-react"; +import { clsx } from "clsx"; +import { useAssets } from "@/lib/query/hooks/useAsset"; +import { Asset, AssetStatus } from "@/lib/query/types/asset"; +import { StatusBadge } from "@/components/assets/status-badge"; + +const STATUS_COLORS: Record = { + [AssetStatus.ACTIVE]: "bg-green-500", + [AssetStatus.ASSIGNED]: "bg-blue-500", + [AssetStatus.MAINTENANCE]: "bg-yellow-500", + [AssetStatus.RETIRED]: "bg-gray-400", +}; + +export default function ReportsPage() { + const { data, isLoading } = useAssets({ page: 1, limit: 1000 }); + + if (isLoading) { + return ( +
+ Loading report… +
+ ); + } + + if (!data) return null; + + const assets = data?.assets ?? []; + const total = assets.length; + + const byStatus = assets.reduce>( + (acc, asset) => { + acc[asset.status] += 1; + return acc; + }, + { + [AssetStatus.ACTIVE]: 0, + [AssetStatus.ASSIGNED]: 0, + [AssetStatus.MAINTENANCE]: 0, + [AssetStatus.RETIRED]: 0, + }, + ); + + const byCategory = Object.values( + assets.reduce>((acc, asset) => { + const categoryName = asset.category?.name ?? "Uncategorized"; + if (!acc[categoryName]) { + acc[categoryName] = { name: categoryName, count: 0 }; + } + acc[categoryName].count += 1; + return acc; + }, {}), + ); + + const byDepartment = Object.values( + assets.reduce>((acc, asset) => { + const departmentName = asset.department?.name ?? "Unassigned"; + if (!acc[departmentName]) { + acc[departmentName] = { name: departmentName, count: 0 }; + } + acc[departmentName].count += 1; + return acc; + }, {}), + ); + + const recent = [...assets] + .sort( + (left, right) => + new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ) + .slice(0, 10); + + const statusItems = Object.entries(byStatus) as [AssetStatus, number][]; + const topCategories = [...byCategory] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + const topDepartments = [...byDepartment] + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + return ( +
+ {/* Header */} +
+

Reports

+

Asset inventory overview

+
+ + {/* Summary cards */} +
+
+

Total Assets

+

{total}

+
+ {statusItems.map(([status, count]) => ( +
+

+ {status.toLowerCase()} +

+

{count}

+
+ ))} +
+ +
+ {/* By Status bar */} +
+

+ + Assets by Status +

+
+ {statusItems.map(([status, count]) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {status.toLowerCase()} + + {count} ({pct}%) + +
+
+
+
+
+ ); + })} +
+
+ + {/* By Category */} +
+

+ + Assets by Category +

+ {topCategories.length === 0 ? ( +

No data

+ ) : ( +
+ {topCategories.map(({ name, count }) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {name} + + {count} ({pct}%) + +
+
+
+
+
+ ); + })} +
+ )} +
+
+ +
+ {/* By Department */} +
+

+ + Assets by Department +

+ {topDepartments.length === 0 ? ( +

No data

+ ) : ( +
+ {topDepartments.map(({ name, count }) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {name} + + {count} ({pct}%) + +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* Recent Assets */} +
+
+

+ + Recently Added +

+ + View all + +
+ {recent.length === 0 ? ( +

+ No assets yet +

+ ) : ( +
+ {recent.map((asset: Asset) => ( + +
+

+ {asset.name} +

+

+ {asset.assetId} · {asset.category?.name ?? "—"} ·{" "} + {format(new Date(asset.createdAt), "MMM d, yyyy")} +

+
+ + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..7203b8d --- /dev/null +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { User, Lock, CheckCircle } from "lucide-react"; +import { useAuthStore } from "@/store/auth.store"; +import { useUpdateProfile } from "@/lib/query/hooks/query.hook"; + +// ── Profile schema ────────────────────────────────────────────── +const profileSchema = z.object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), +}); +type ProfileForm = z.infer; + +// ── Password schema ───────────────────────────────────────────── +const passwordSchema = z + .object({ + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string(), + }) + .refine((d) => d.password === d.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); +type PasswordForm = z.infer; + +export default function SettingsPage() { + const { user } = useAuthStore(); + const updateProfile = useUpdateProfile(); + const [profileSaved, setProfileSaved] = useState(false); + const [passwordSaved, setPasswordSaved] = useState(false); + + const profileForm = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + firstName: user?.firstName ?? "", + lastName: user?.lastName ?? "", + }, + }); + + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "", confirmPassword: "" }, + }); + + const onProfileSubmit = (data: ProfileForm) => { + updateProfile.mutate(data, { + onSuccess: () => { + setProfileSaved(true); + setTimeout(() => setProfileSaved(false), 3000); + }, + }); + }; + + const onPasswordSubmit = (data: PasswordForm) => { + updateProfile.mutate( + { password: data.password }, + { + onSuccess: () => { + setPasswordSaved(true); + passwordForm.reset(); + setTimeout(() => setPasswordSaved(false), 3000); + }, + }, + ); + }; + + return ( +
+ {/* Header */} +
+

Settings

+

+ Manage your profile and account preferences +

+
+ + {/* Profile section */} +
+
+
+ +
+
+

Profile

+

Update your display name

+
+
+ +
+
+
+ + + {profileForm.formState.errors.firstName && ( +

+ {profileForm.formState.errors.firstName.message} +

+ )} +
+
+ + + {profileForm.formState.errors.lastName && ( +

+ {profileForm.formState.errors.lastName.message} +

+ )} +
+
+ + {/* Email (read-only) */} +
+ + +

+ Email cannot be changed. +

+
+ + {/* Role (read-only) */} +
+ + +
+ +
+ + {profileSaved && ( + + + Saved successfully + + )} + {updateProfile.isError && ( + + Failed to save. Try again. + + )} +
+
+
+ + {/* Password section */} +
+
+
+ +
+
+

+ Change Password +

+

+ Choose a strong password (min. 8 characters) +

+
+
+ +
+
+ + + {passwordForm.formState.errors.password && ( +

+ {passwordForm.formState.errors.password.message} +

+ )} +
+ +
+ + + {passwordForm.formState.errors.confirmPassword && ( +

+ {passwordForm.formState.errors.confirmPassword.message} +

+ )} +
+ +
+ + {passwordSaved && ( + + + Password updated + + )} +
+
+
+
+ ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b2dabd..619fff0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,7 +18,6 @@ "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", "clsx": "^2.1.1", - "create-next-app": "^15.5.9", "date-fns": "^4.1.0", "jspdf": "^4.0.0", "lucide-react": "^0.562.0", @@ -4497,18 +4496,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/create-next-app": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/create-next-app/-/create-next-app-15.5.9.tgz", - "integrity": "sha512-B489ZPUb555IuCM4Ggevi3dzHqpzdZXUkEAT/RFz8JKG3HtxYC8PacOIjSKKgXDfbsHza+ckmoScjwRgaV4L7w==", - "license": "MIT", - "bin": { - "create-next-app": "dist/index.js" - }, - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/frontend/store/auth.store.ts b/frontend/store/auth.store.ts index 2615e1d..9949964 100644 --- a/frontend/store/auth.store.ts +++ b/frontend/store/auth.store.ts @@ -4,7 +4,10 @@ import { persist } from 'zustand/middleware'; interface User { id: string; email: string; - name: string; + firstName: string; + lastName: string; + role: string; + name?: string; } interface AuthStore {