img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/my-app/src/components/ui/checkbox.tsx b/my-app/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..4fcd847
--- /dev/null
+++ b/my-app/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
+
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "lucide-react"
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/my-app/src/components/ui/dialog.tsx b/my-app/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/my-app/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/my-app/src/components/ui/dropdown-menu.tsx b/my-app/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/my-app/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,268 @@
+"use client"
+
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/my-app/src/components/ui/input.tsx b/my-app/src/components/ui/input.tsx
new file mode 100644
index 0000000..85e1b45
--- /dev/null
+++ b/my-app/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/my-app/src/components/ui/label.tsx b/my-app/src/components/ui/label.tsx
new file mode 100644
index 0000000..74da65c
--- /dev/null
+++ b/my-app/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/my-app/src/components/ui/pagination.tsx b/my-app/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..db376f6
--- /dev/null
+++ b/my-app/src/components/ui/pagination.tsx
@@ -0,0 +1,130 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ )
+}
+
+function PaginationContent({
+ className,
+ ...props
+}: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+ return
+}
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick, "size"> &
+ React.ComponentProps<"a">
+
+function PaginationLink({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) {
+ return (
+
+ }
+ />
+ )
+}
+
+function PaginationPrevious({
+ className,
+ text = "Previous",
+ ...props
+}: React.ComponentProps & { text?: string }) {
+ return (
+
+
+ {text}
+
+ )
+}
+
+function PaginationNext({
+ className,
+ text = "Next",
+ ...props
+}: React.ComponentProps & { text?: string }) {
+ return (
+
+ {text}
+
+
+ )
+}
+
+function PaginationEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More pages
+
+ )
+}
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+}
diff --git a/my-app/src/components/ui/progress.tsx b/my-app/src/components/ui/progress.tsx
new file mode 100644
index 0000000..986f346
--- /dev/null
+++ b/my-app/src/components/ui/progress.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ children,
+ value,
+ ...props
+}: ProgressPrimitive.Root.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
+ return (
+
+ )
+}
+
+function ProgressIndicator({
+ className,
+ ...props
+}: ProgressPrimitive.Indicator.Props) {
+ return (
+
+ )
+}
+
+function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
+ return (
+
+ )
+}
+
+function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+export {
+ Progress,
+ ProgressTrack,
+ ProgressIndicator,
+ ProgressLabel,
+ ProgressValue,
+}
diff --git a/my-app/src/components/ui/scroll-area.tsx b/my-app/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/my-app/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/my-app/src/components/ui/select.tsx b/my-app/src/components/ui/select.tsx
new file mode 100644
index 0000000..49e1657
--- /dev/null
+++ b/my-app/src/components/ui/select.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/my-app/src/components/ui/separator.tsx b/my-app/src/components/ui/separator.tsx
new file mode 100644
index 0000000..bc373d8
--- /dev/null
+++ b/my-app/src/components/ui/separator.tsx
@@ -0,0 +1,132 @@
+"use client"
+
+import { useRef, useLayoutEffect, useCallback } from "react"
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+/**
+ * Calculates the exact left offset and rendered text width of the previous sibling's text content.
+ */
+function getSiblingContentBounds(prevSibling: HTMLElement, parent: HTMLElement) {
+ const parentRect = parent.getBoundingClientRect()
+
+ // 1. Traverse all non-empty text nodes inside the sibling
+ const textNodes: Node[] = []
+ const walk = document.createTreeWalker(prevSibling, NodeFilter.SHOW_TEXT, null)
+ let node: Node | null
+ while ((node = walk.nextNode())) {
+ if (node.textContent && node.textContent.trim().length > 0) {
+ textNodes.push(node)
+ }
+ }
+
+ if (textNodes.length > 0) {
+ const range = document.createRange()
+ try {
+ range.setStart(textNodes[0], 0)
+ const lastNode = textNodes[textNodes.length - 1]
+ range.setEnd(lastNode, lastNode.textContent?.length || 0)
+ const rect = range.getBoundingClientRect()
+ if (rect.width > 0) {
+ return {
+ left: rect.left - parentRect.left,
+ width: rect.width,
+ }
+ }
+ } catch {
+ // ignore range measurement errors
+ }
+ }
+
+ // 2. Fallback for non-text graphic elements (e.g. logo image/SVG)
+ const graphics = prevSibling.querySelectorAll("img, svg")
+ if (graphics.length > 0) {
+ let minLeft = Infinity
+ let maxRight = -Infinity
+ graphics.forEach((c) => {
+ const r = c.getBoundingClientRect()
+ if (r.width > 0) {
+ if (r.left < minLeft) minLeft = r.left
+ if (r.right > maxRight) maxRight = r.right
+ }
+ })
+ if (minLeft !== Infinity && maxRight > minLeft) {
+ return {
+ left: minLeft - parentRect.left,
+ width: maxRight - minLeft,
+ }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Separator that automatically sizes itself to be 5-10px wider (specifically 8px wider)
+ * than the text content of its previous sibling element, aligned directly with the text.
+ */
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ const ref = useRef(null)
+
+ const updateWidth = useCallback(() => {
+ const el = ref.current
+ if (!el || orientation !== "horizontal" || el.hasAttribute("data-no-autosize")) return
+
+ const prevSibling = el.previousElementSibling as HTMLElement | null
+ if (!prevSibling) return
+
+ const parent = el.parentElement
+ if (!parent) return
+
+ const bounds = getSiblingContentBounds(prevSibling, parent)
+ if (!bounds || bounds.width === 0) return
+
+ // 8px wider total (extends 4px to the left and 4px to the right of the text)
+ const targetWidth = bounds.width + 8
+ const targetLeft = Math.max(0, bounds.left - 4)
+
+ el.style.width = `${targetWidth}px`
+ el.style.marginLeft = `${targetLeft}px`
+ }, [orientation])
+
+ useLayoutEffect(() => {
+ const el = ref.current
+ if (!el || orientation !== "horizontal" || el.hasAttribute("data-no-autosize")) return
+
+ // Initial calculation
+ updateWidth()
+
+ // Observe changes to sibling and parent
+ const prevSibling = el.previousElementSibling
+ const parent = el.parentElement
+
+ const observer = new ResizeObserver(() => {
+ updateWidth()
+ })
+
+ if (prevSibling) observer.observe(prevSibling)
+ if (parent) observer.observe(parent)
+
+ return () => observer.disconnect()
+ }, [orientation, updateWidth])
+
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/my-app/src/components/ui/sheet.tsx b/my-app/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..19894e0
--- /dev/null
+++ b/my-app/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
+ return
+}
+
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
+ return
+}
+
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
+ return
+}
+
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
+ return
+}
+
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: SheetPrimitive.Popup.Props & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: SheetPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/my-app/src/components/ui/slider.tsx b/my-app/src/components/ui/slider.tsx
new file mode 100644
index 0000000..9391610
--- /dev/null
+++ b/my-app/src/components/ui/slider.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface SliderProps {
+ value?: number[];
+ defaultValue?: number[];
+ max?: number;
+ min?: number;
+ step?: number;
+ onValueChange?: (value: number[]) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+const Slider = React.forwardRef(
+ (
+ {
+ value,
+ defaultValue = [0],
+ max = 100,
+ min = 0,
+ step = 1,
+ onValueChange,
+ className,
+ disabled = false,
+ },
+ ref
+ ) => {
+ const currentValue = value ?? defaultValue;
+ const percentage = ((currentValue[0] - min) / (max - min)) * 100;
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const newValue = Number(e.target.value);
+ onValueChange?.([newValue]);
+ };
+
+ return (
+
+ );
+ }
+);
+
+Slider.displayName = "Slider";
+
+export { Slider };
diff --git a/my-app/src/components/ui/switch.tsx b/my-app/src/components/ui/switch.tsx
new file mode 100644
index 0000000..e585921
--- /dev/null
+++ b/my-app/src/components/ui/switch.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface SwitchProps {
+ checked?: boolean;
+ defaultChecked?: boolean;
+ onCheckedChange?: (checked: boolean) => void;
+ disabled?: boolean;
+ className?: string;
+ id?: string;
+}
+
+const Switch = React.forwardRef(
+ ({ checked, defaultChecked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
+ const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
+ const isControlled = checked !== undefined;
+ const isOn = isControlled ? checked : internalChecked;
+
+ const toggle = () => {
+ if (disabled) return;
+ const newValue = !isOn;
+ if (!isControlled) setInternalChecked(newValue);
+ onCheckedChange?.(newValue);
+ };
+
+ return (
+
+
+
+ );
+ }
+);
+
+Switch.displayName = "Switch";
+
+export { Switch };
diff --git a/my-app/src/components/ui/table.tsx b/my-app/src/components/ui/table.tsx
new file mode 100644
index 0000000..0162324
--- /dev/null
+++ b/my-app/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/my-app/src/components/ui/tabs.tsx b/my-app/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..9c5390e
--- /dev/null
+++ b/my-app/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-full border border-slate-200 bg-white p-1 text-slate-500 shadow-none group-data-horizontal/tabs:h-auto group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-white",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+
+ )
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/my-app/src/components/ui/toast-card.tsx b/my-app/src/components/ui/toast-card.tsx
new file mode 100644
index 0000000..7aabd0a
--- /dev/null
+++ b/my-app/src/components/ui/toast-card.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import React from "react";
+import { cn } from "@/lib/utils";
+
+export interface ToastCardProps {
+ message: string;
+ variant?: "success" | "destructive" | "info";
+ className?: string;
+}
+
+export function ToastCard({ message, variant = "success", className }: ToastCardProps) {
+ if (!message) return null;
+
+ const isDestructive = variant === "destructive";
+
+ const cardBg = isDestructive
+ ? "bg-[#D32F2F] text-white"
+ : "bg-[#046A38] text-white";
+
+ return (
+
+ );
+}
diff --git a/my-app/src/components/ui/tooltip.tsx b/my-app/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/my-app/src/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/my-app/src/lib/api.ts b/my-app/src/lib/api.ts
new file mode 100644
index 0000000..6ede228
--- /dev/null
+++ b/my-app/src/lib/api.ts
@@ -0,0 +1,191 @@
+/**
+ * Uses the Next.js proxy by default to avoid CORS and avoid exposing the
+ * backend address in the browser. Can be overridden via NEXT_PUBLIC_API_URL
+ * for deployments that talk directly to the backend.
+ */
+export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/backend";
+
+/** Name of the HttpOnly cookie issued by the backend after authentication. */
+export const AUTH_COOKIE_NAME = "JWT";
+
+export class ApiError extends Error {
+ status: number;
+
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+export interface ApiPage {
+ content: T[];
+ totalElements?: number;
+ totalPages?: number;
+ number?: number;
+ size?: number;
+}
+
+/**
+ * The Spring endpoints expose their `find/all` and `search` results as a
+ * `Page`. Older deployments returned a plain array, so accepting both
+ * shapes keeps the client compatible during backend rollouts.
+ */
+export function unwrapCollection(payload: T[] | ApiPage): T[] {
+ if (Array.isArray(payload)) return payload;
+ if (payload && Array.isArray(payload.content)) return payload.content;
+ return [];
+}
+
+// Static messages prevent internal infrastructure details from leaking into the UI.
+// 502/503 share a message intentionally — from the user's perspective the distinction
+// between "gateway bad" and "service unavailable" is meaningless.
+const HTTP_ERROR_MESSAGES: Partial> = {
+ 500: "O servidor encontrou um erro interno. Tente novamente mais tarde.",
+ 502: "O servidor está temporariamente indisponível. Tente novamente em alguns minutos.",
+ 503: "O servidor está temporariamente indisponível. Tente novamente em alguns minutos.",
+ 504: "O servidor demorou demais para responder. Tente novamente em alguns minutos.",
+};
+
+function defaultErrorMessage(status: number): string {
+ return HTTP_ERROR_MESSAGES[status] || `Erro HTTP! Status: ${status}`;
+}
+
+interface CsrfTokenResponse {
+ headerName: string;
+ token: string;
+}
+
+function isMutation(method: string) {
+ return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase());
+}
+
+async function getCsrfToken(): Promise {
+ const response = await fetch(`${API_BASE_URL}/auth/csrf`, {
+ method: 'GET',
+ credentials: 'include',
+ cache: 'no-store',
+ }).catch(() => {
+ throw new ApiError('Não foi possível obter a proteção CSRF do servidor.', 0);
+ });
+
+ if (!response.ok) {
+ throw new ApiError(defaultErrorMessage(response.status), response.status);
+ }
+
+ const payload = await response.json() as Partial;
+ if (!payload.headerName || !payload.token) {
+ throw new ApiError('O servidor não retornou um token CSRF válido.', 502);
+ }
+
+ return { headerName: payload.headerName, token: payload.token };
+}
+
+/**
+ * Centralised HTTP wrapper for all Spring Boot API calls.
+ *
+ * Always sends `credentials: "include"` so the browser attaches and receives
+ * the HttpOnly JWT cookie on every request — this is required by the backend
+ * session model and must not be removed.
+ *
+ * FormData bodies are excluded from the automatic Content-Type injection
+ * because the browser must set it itself (with the correct multipart boundary).
+ */
+export async function apiFetch(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise {
+ const url = endpoint.startsWith("http") ? endpoint : `${API_BASE_URL}${endpoint}`;
+
+ const headers = new Headers(options.headers || {});
+ if (!headers.has("Content-Type") && options.body && !(options.body instanceof FormData)) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const method = (options.method || 'GET').toUpperCase();
+ const csrfProtected = isMutation(method) && endpoint !== '/auth/csrf';
+
+ if (csrfProtected) {
+ // Always read a token immediately before an unsafe request. Besides sending
+ // the XSRF cookie, this prevents a token cached before a login/session
+ // rotation from being paired with the current JWT cookie.
+ const csrf = await getCsrfToken();
+ headers.set(csrf.headerName, csrf.token);
+ }
+
+ let response: Response;
+
+ const send = () => fetch(url, {
+ ...options,
+ method,
+ headers,
+ credentials: 'include',
+ });
+
+ try {
+ response = await send();
+ } catch {
+ throw new ApiError(
+ "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.",
+ // Status 0 signals a network-level failure (no HTTP response at all).
+ 0
+ );
+ }
+
+ if (!response.ok) {
+ let errorMessage = defaultErrorMessage(response.status);
+ try {
+ // Keep infrastructure failures on the safe, localized messages above.
+ // Backend 5xx bodies may expose internal details and are not actionable.
+ if (response.status >= 500) {
+ throw new Error('skip-server-error-body');
+ }
+ const contentType = response.headers.get("content-type") || "";
+ // The API uses { message }, but short plain-text responses are also accepted.
+ // HTML is ignored to avoid rendering entire Spring error pages in the UI.
+ if (contentType.includes("application/json")) {
+ const errorData: unknown = await response.json();
+ if (
+ typeof errorData === "object" &&
+ errorData !== null &&
+ "message" in errorData &&
+ typeof errorData.message === "string" &&
+ errorData.message.trim()
+ ) {
+ errorMessage = errorData.message;
+ }
+ } else if (contentType.includes("text/plain")) {
+ // Length guard prevents truncated 50 KB server-generated text pages from reaching users.
+ const errorText = (await response.text()).trim();
+ if (errorText && errorText.length <= 500) {
+ errorMessage = errorText;
+ }
+ }
+ } catch {
+ // Parsing the error body itself failed — fall through to the default message.
+ }
+
+ throw new ApiError(errorMessage, response.status);
+ }
+
+ // 204 No Content — return an empty object typed as T rather than trying to parse
+ // an empty body (which would throw a JSON parse error).
+ if (response.status === 204) {
+ return {} as T;
+ }
+
+ try {
+ return (await response.json()) as T;
+ } catch {
+ // Body was unexpectedly empty or non-JSON on a 2xx response — safe to swallow.
+ return {} as T;
+ }
+}
+
+export async function apiFetchCollection(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise {
+ const payload = await apiFetch>(endpoint, options);
+ return unwrapCollection(payload);
+}
diff --git a/my-app/src/lib/application-api.ts b/my-app/src/lib/application-api.ts
new file mode 100644
index 0000000..87d29c7
--- /dev/null
+++ b/my-app/src/lib/application-api.ts
@@ -0,0 +1,426 @@
+import { ApiError, apiFetch, apiFetchCollection } from '@/lib/api';
+import {
+ mockClasses,
+ mockCourses,
+ mockInterviews,
+ mockStudents,
+ mockUsers,
+} from '@/lib/mock-data';
+import { apiFirst, createLocalId, readCollection, writeCollection } from '@/lib/offline-store';
+import type {
+ ClassDTO,
+ CourseDTO,
+ InterviewDTO,
+ StudentDTO,
+ UserDTO,
+ VacancyDTO,
+} from '@/types';
+import {
+ deleteVacancy as deleteManagerVacancy,
+ getPlaces as getManagerPlaces,
+ getVacancies as getManagerVacancies,
+ getVacancy as getManagerVacancy,
+ updateVacancy as updateManagerVacancy,
+ type VacancyArea,
+ type VacancyShift,
+} from '@/lib/manager-api';
+
+import { ROLE_COOKIE_NAME, USER_ID_COOKIE_NAME } from '@/lib/auth';
+
+function readClientCookie(name: string) {
+ if (typeof document === 'undefined') return undefined;
+ const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
+ return match ? decodeURIComponent(match[1]) : undefined;
+}
+
+interface BackendUser {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+}
+
+interface BackendCourse {
+ id: string;
+ courseName: string;
+ coordinatorName: string;
+ coordinatorEmail: string;
+}
+
+interface BackendClass {
+ id: string;
+ courseName: string;
+ startDate: string;
+ finishDate: string;
+ status: string;
+ shiftClass: string;
+ acronym: string;
+}
+
+interface BackendInterview {
+ id: string;
+ interviewerName: string;
+ dateTime: string;
+ park: string;
+ section: string;
+ nameStudent: string;
+ nameManager: string;
+ shift: string;
+}
+
+interface BackendStudent {
+ id: string;
+ name: string;
+ email: string;
+ averageGrade?: number;
+ acronym?: string;
+ course?: string;
+ statusStudent?: string;
+}
+
+function mapUser(item: BackendUser, role: UserDTO['role']): UserDTO {
+ return { id: item.id, name: item.name, email: item.email, role, active: true };
+}
+
+function mapCourse(item: BackendCourse): CourseDTO {
+ return {
+ id: item.id,
+ name: item.courseName,
+ code: item.id.slice(0, 8).toUpperCase(),
+ coordinatorName: item.coordinatorName,
+ totalStudents: 0,
+ status: 'ACTIVE',
+ };
+}
+
+function mapClass(item: BackendClass): ClassDTO {
+ const status: ClassDTO['status'] = item.status === 'FINISHED'
+ ? 'COMPLETED'
+ : item.status === 'ON_GOING' ? 'IN_PROGRESS' : 'PLANNED';
+ return {
+ id: item.id,
+ name: `${item.courseName} - ${item.acronym}`,
+ code: item.acronym,
+ courseName: item.courseName,
+ period: item.shiftClass === 'MORNING' ? 'Matutino' : 'Vespertino',
+ totalStudents: 0,
+ maxStudents: 25,
+ status,
+ };
+}
+
+function mapInterview(item: BackendInterview): InterviewDTO {
+ const [scheduledDate = '', scheduledTimeWithZone = ''] = item.dateTime.split('T');
+ return {
+ id: item.id,
+ candidateName: item.nameStudent,
+ candidateEmail: '',
+ vacancyTitle: [item.park, item.section].filter(Boolean).join(' / '),
+ scheduledDate,
+ scheduledTime: scheduledTimeWithZone.slice(0, 5),
+ interviewerName: item.interviewerName || item.nameManager,
+ status: 'SCHEDULED',
+ };
+}
+
+function mapStudent(item: BackendStudent): StudentDTO {
+ const status: StudentDTO['status'] = item.statusStudent === 'ENROLLED'
+ ? 'ACTIVE'
+ : item.statusStudent === 'FIRED' ? 'PAUSED' : 'COMPLETED';
+ return {
+ id: item.id,
+ name: item.name,
+ registration: item.id.slice(0, 8).toUpperCase(),
+ email: item.email,
+ courseName: item.course ?? '',
+ className: item.acronym ?? '',
+ status,
+ performanceGrade: item.averageGrade,
+ };
+}
+
+function replaceLocal(key: string, seed: readonly T[], value: T) {
+ const current = readCollection(key, seed);
+ writeCollection(key, current.map((item) => item.id === value.id ? value : item));
+ return value;
+}
+
+function deleteLocal(key: string, seed: readonly T[], id: string) {
+ writeCollection(key, readCollection(key, seed).filter((item) => item.id !== id));
+ return {};
+}
+
+export function getAdminVacancies() {
+ return getManagerVacancies().then((data): VacancyDTO[] => data.map((item) => ({
+ id: item.id, title: item.name, department: item.section,
+ location: item.park, totalSpots: item.numbersVacancies,
+ filledSpots: 0, status: 'OPEN',
+ })));
+}
+
+export async function updateAdminVacancy(item: VacancyDTO) {
+ const [current, places] = await Promise.all([
+ getManagerVacancy(item.id),
+ getManagerPlaces(),
+ ]);
+ const place = places.find((value) =>
+ value.park === item.location || value.placeName === item.location
+ ) ?? places[0];
+ if (!place) throw new Error('Cadastre um local antes de atualizar a vaga.');
+ const updated = await updateManagerVacancy(item.id, {
+ name: item.title,
+ description: item.department,
+ area: current.area as VacancyArea,
+ shift: current.shift as VacancyShift,
+ placeId: place.id,
+ });
+ return {
+ ...item,
+ title: updated.name,
+ department: updated.section,
+ location: updated.park,
+ };
+}
+
+export async function deleteAdminVacancy(id: string) {
+ return deleteManagerVacancy(id);
+}
+
+export function getAppUsers() {
+ return apiFirst(
+ async () => {
+ const [admins, managers, coordinators] = await Promise.all([
+ apiFetchCollection('/admin/find/all'),
+ apiFetchCollection('/manager/find/all'),
+ apiFetchCollection('/coordinator/find/all'),
+ ]);
+ return [
+ ...admins.map((item) => mapUser(item, 'ADMIN')),
+ ...managers.map((item) => mapUser(item, 'MANAGER')),
+ ...coordinators.map((item) => mapUser(item, 'COORDINATOR')),
+ ];
+ },
+ () => readCollection('users', mockUsers)
+ );
+}
+
+export function updateAppUser(user: UserDTO) {
+ return apiFirst(
+ async () => {
+ const updated = await apiFetch(`/user/update/${encodeURIComponent(user.id)}`, {
+ method: 'PATCH', body: JSON.stringify({ name: user.name }),
+ });
+ return { ...user, name: updated.name, email: updated.email };
+ },
+ () => replaceLocal('users', mockUsers, user)
+ );
+}
+
+export function deleteAppUser(id: string) {
+ return apiFirst(
+ () => apiFetch(`/user/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => deleteLocal('users', mockUsers, id)
+ );
+}
+
+export function getCourses() {
+ return apiFirst(
+ async () => (await apiFetchCollection('/course/find/all')).map(mapCourse),
+ () => readCollection('courses', mockCourses)
+ );
+}
+
+export function createCourse(input: Omit) {
+ return apiFirst(
+ async () => {
+ const role = readClientCookie(ROLE_COOKIE_NAME)?.toUpperCase();
+ let coordinatorId = role === 'COORDINATOR'
+ ? readClientCookie(USER_ID_COOKIE_NAME)
+ : undefined;
+
+ if (!coordinatorId) {
+ const coordinators = await apiFetchCollection('/coordinator/find/all');
+ coordinatorId = coordinators.find((item) =>
+ item.name === input.coordinatorName ||
+ item.username === input.coordinatorName ||
+ item.email === input.coordinatorName
+ )?.id;
+ }
+
+ if (!coordinatorId) {
+ throw new ApiError('Não foi possível identificar o coordenador responsável.', 422);
+ }
+ const created = await apiFetch('/course/create', {
+ method: 'POST',
+ body: JSON.stringify({ name: input.name, coordinatorId }),
+ });
+ return mapCourse(created);
+ },
+ () => {
+ const created = { id: createLocalId('crs'), ...input };
+ writeCollection('courses', [...readCollection('courses', mockCourses), created]);
+ return created;
+ }
+ );
+}
+
+export function updateCourse(course: CourseDTO) {
+ return apiFirst(
+ async () => {
+ const role = readClientCookie(ROLE_COOKIE_NAME)?.toUpperCase();
+ let coordinatorId = role === 'COORDINATOR'
+ ? readClientCookie(USER_ID_COOKIE_NAME)
+ : undefined;
+ if (!coordinatorId) {
+ const coordinators = await apiFetchCollection('/coordinator/find/all');
+ coordinatorId = coordinators.find((item) =>
+ item.name === course.coordinatorName ||
+ item.username === course.coordinatorName ||
+ item.email === course.coordinatorName
+ )?.id;
+ }
+ const updated = await apiFetch(`/course/update/${encodeURIComponent(course.id)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ name: course.name,
+ ...(coordinatorId ? { coordinatorId } : {}),
+ }),
+ });
+ return mapCourse(updated);
+ },
+ () => replaceLocal('courses', mockCourses, course)
+ );
+}
+
+export function deleteCourse(id: string) {
+ return apiFirst(
+ () => apiFetch(`/course/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => deleteLocal('courses', mockCourses, id)
+ );
+}
+
+export function getClasses() {
+ return apiFirst(
+ async () => (await apiFetchCollection('/class/find/all')).map(mapClass),
+ () => readCollection('classes', mockClasses)
+ );
+}
+
+export function createClass(input: Omit & {
+ courseId?: string;
+ startDate?: string;
+ finishDate?: string;
+ studentIds?: string[];
+}) {
+ return apiFirst(
+ async () => {
+ if (!input.courseId || !input.startDate || !input.finishDate) {
+ throw new ApiError('Informe curso, data inicial e data final da turma.', 422);
+ }
+ const created = await apiFetch('/class/create', {
+ method: 'POST',
+ body: JSON.stringify({
+ courseId: input.courseId,
+ startDate: input.startDate,
+ finishDate: input.finishDate,
+ status: input.status === 'COMPLETED' ? 'FINISHED' : input.status === 'IN_PROGRESS' ? 'ON_GOING' : 'NOT_STARTED',
+ shiftClass: input.period === 'Matutino' ? 'MORNING' : 'AFTERNOON',
+ acronym: input.code,
+ }),
+ });
+ await Promise.all((input.studentIds ?? []).map((studentId) =>
+ apiFetch(`/student/update/${encodeURIComponent(studentId)}`, {
+ method: 'PATCH', body: JSON.stringify({ classId: created.id }),
+ })
+ ));
+ return mapClass(created);
+ },
+ () => {
+ const created: ClassDTO = {
+ id: createLocalId('cls'), name: input.name, code: input.code,
+ courseName: input.courseName, period: input.period,
+ totalStudents: input.totalStudents, maxStudents: input.maxStudents,
+ status: input.status,
+ };
+ writeCollection('classes', [...readCollection('classes', mockClasses), created]);
+ return created;
+ }
+ );
+}
+
+export function updateClass(item: ClassDTO) {
+ return apiFirst(
+ async () => mapClass(await apiFetch(`/class/update/${encodeURIComponent(item.id)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ acronym: item.code,
+ shiftClass: item.period === 'Matutino' ? 'MORNING' : 'AFTERNOON',
+ status: item.status === 'COMPLETED' ? 'FINISHED' : item.status === 'IN_PROGRESS' ? 'ON_GOING' : 'NOT_STARTED',
+ }),
+ })),
+ () => replaceLocal('classes', mockClasses, item)
+ );
+}
+
+export function deleteClass(id: string) {
+ return apiFirst(
+ () => apiFetch(`/class/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => deleteLocal('classes', mockClasses, id)
+ );
+}
+
+export function getAdminInterviews() {
+ return apiFirst(
+ async () => (await apiFetchCollection('/interview/find/all')).map(mapInterview),
+ () => readCollection('admin-interviews', mockInterviews)
+ );
+}
+
+export function getAppStudents() {
+ return apiFirst(
+ async () => (await apiFetchCollection('/student/find/all')).map(mapStudent),
+ () => readCollection('students', mockStudents)
+ );
+}
+
+export interface CreateStudentInput {
+ name: string;
+ email: string;
+ age: number;
+ classId: string;
+}
+
+export function createAppStudent(input: CreateStudentInput) {
+ return apiFetch('/student/create', {
+ method: 'POST',
+ body: JSON.stringify({
+ ...input,
+ statusStudentInterview: 'NOT_ASSOCIATED',
+ hasSeenEmail: false,
+ }),
+ }).then(mapStudent);
+}
+
+export function updateAppStudent(student: StudentDTO) {
+ return apiFirst(
+ async () => mapStudent(await apiFetch(`/student/update/${encodeURIComponent(student.id)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({
+ name: student.name,
+ email: student.email,
+ averageGrade: student.performanceGrade,
+ statusStudent: student.status === 'ACTIVE' ? 'ENROLLED' : student.status === 'PAUSED' ? 'FIRED' : 'LEFT',
+ }),
+ })),
+ () => replaceLocal('students', mockStudents, student)
+ );
+}
+
+export async function directStudentToVacancy(studentId: string, vacancyId: string) {
+ void studentId;
+ void vacancyId;
+ throw new ApiError(
+ 'A API atual não possui um endpoint que associe diretamente um aluno a uma vaga.',
+ 501
+ );
+}
diff --git a/my-app/src/lib/auth.ts b/my-app/src/lib/auth.ts
new file mode 100644
index 0000000..11af739
--- /dev/null
+++ b/my-app/src/lib/auth.ts
@@ -0,0 +1,138 @@
+import { UserRole } from "@/types";
+
+/**
+ * Authentication JWT session cookie name.
+ * Must match the name the Spring Boot backend uses when issuing the HttpOnly cookie.
+ */
+export const AUTH_COOKIE_NAME = "JWT";
+
+/**
+ * User role cookie name.
+ * Written client-side after login so the Next.js middleware can read it during SSR
+ * (HttpOnly cookies are invisible to JS, so the role needs its own readable cookie).
+ */
+export const ROLE_COOKIE_NAME = "userRole";
+
+export const USER_ID_COOKIE_NAME = 'userId';
+export const USER_NAME_COOKIE_NAME = 'userName';
+
+/**
+ * Checks only whether a JWT has a valid shape and has not expired.
+ * Intentionally does NOT verify the signature — that is the backend's responsibility
+ * on every authenticated request. This function is used only for frontend routing
+ * to avoid unnecessary round-trips for obviously stale tokens.
+ *
+ * Uses Base64URL decoding (RFC 7515 §2) because browsers' `atob` expects standard
+ * Base64; the `-` → `+` and `_` → `/` replacements plus padding are required.
+ */
+export function isJwtFresh(token?: string | null): boolean {
+ if (!token) return false;
+
+ try {
+ const parts = token.split(".");
+ if (parts.length !== 3) return false;
+
+ const normalizedPayload = parts[1]
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
+ const payload: unknown = JSON.parse(atob(normalizedPayload));
+
+ return (
+ typeof payload === "object" &&
+ payload !== null &&
+ "exp" in payload &&
+ typeof payload.exp === "number" &&
+ // `exp` is in seconds; Date.now() is in ms.
+ payload.exp * 1000 > Date.now()
+ );
+ } catch {
+ // Malformed token — treat as expired.
+ return false;
+ }
+}
+
+/**
+ * Returns the landing route for a given role after login or an unauthorized redirect.
+ * Defaults to `/dashboard` when the role is unknown, rather than blocking the user.
+ */
+export function getRedirectPathByRole(role?: UserRole | string | null): string {
+ if (!role) return "/login";
+
+ const normalizedRole = role.toUpperCase();
+
+ switch (normalizedRole) {
+ case "ADMIN":
+ return "/admin";
+ case "COORDINATOR":
+ case "COORDENADOR":
+ return "/dashboard";
+ case "MANAGER":
+ case "GESTOR":
+ return "/manager/vacancies";
+ default:
+ // Um papel desconhecido não possui rota inicial. Enviá-lo para /dashboard
+ // criava um loop, pois essa rota é exclusiva do coordenador.
+ return "/login";
+ }
+}
+
+/**
+ * Determines whether a pathname is accessible for a given role.
+ *
+ * Rules (in evaluation order):
+ * 1. No role cookie → deny protected navigation.
+ * 2. ADMIN → the five admin areas plus every manager/coordinator workflow.
+ * 3. /admin/* → ADMIN-only; legacy admin pages remain unavailable.
+ * 4. /manager/* → MANAGER-only, except that ADMIN may also operate it.
+ * 5. Coordinator routes → COORDINATOR-only, except for ADMIN.
+ * 6. Anything else (for example, shared utilities) → allow.
+ *
+ * Note: Portuguese aliases (COORDENADOR, GESTOR) are accepted because the backend
+ * may return either form depending on the API version.
+ */
+export function isRouteAllowedForRole(pathname: string, role?: UserRole | string | null): boolean {
+ if (!role) return false;
+
+ const normalizedRole = role.toUpperCase();
+
+ // ADMIN can access everything
+ if (normalizedRole === "ADMIN") {
+ if (pathname === "/admin" || pathname.startsWith("/admin/")) {
+ return (
+ pathname === "/admin" ||
+ pathname === "/admin/users" ||
+ pathname.startsWith("/admin/users/") ||
+ pathname === "/admin/locations" ||
+ pathname.startsWith("/admin/locations/") ||
+ pathname === "/admin/courses" ||
+ pathname.startsWith("/admin/courses/") ||
+ pathname === "/admin/vacancies" ||
+ pathname.startsWith("/admin/vacancies/")
+ );
+ }
+
+ // Administrators can also operate every manager and coordinator workflow.
+ return true;
+ }
+
+ if (pathname.startsWith("/admin")) {
+ return false;
+ }
+
+ // Locais são mantidos exclusivamente pelo administrador.
+ if (pathname === "/manager/locations" || pathname.startsWith("/manager/locations/")) {
+ return false;
+ }
+
+ if (pathname.startsWith("/manager")) {
+ return normalizedRole === "MANAGER" || normalizedRole === "GESTOR";
+ }
+
+ const coordinatorRoutes = ["/dashboard", "/classes", "/students", "/courses", "/coordinator"];
+ if (coordinatorRoutes.some((route) => pathname === route || pathname.startsWith(route + "/"))) {
+ return normalizedRole === "COORDINATOR" || normalizedRole === "COORDENADOR";
+ }
+
+ return true;
+}
diff --git a/my-app/src/lib/manager-api.ts b/my-app/src/lib/manager-api.ts
new file mode 100644
index 0000000..8b9f117
--- /dev/null
+++ b/my-app/src/lib/manager-api.ts
@@ -0,0 +1,581 @@
+import { apiFetch, apiFetchCollection } from "@/lib/api";
+import { mockInterviews, mockPlaces, mockStudents, mockUsers, mockVacancies } from '@/lib/mock-data';
+import { apiFirst, createLocalId, readCollection, writeCollection } from '@/lib/offline-store';
+
+// ── Enumerations ──
+
+// VacancyArea and VacancyShift are kept as open string unions (| string) to
+// remain forward-compatible if the backend adds new values before the frontend
+// is updated. Exhaustive switch/case over these should always include a default.
+export type VacancyArea = "IT" | "MAINTENANCE" | "TOOLING" | "CHEMISTRY";
+export type VacancyShift = "FIRST" | "SECOND" | "THIRD" | "FLEXIBLE_SHIFT";
+
+// InterviewStatus tracks the lifecycle of a candidate through the selection process.
+// NOT_ASSOCIATED means no interview has been linked to the student yet.
+export type InterviewStatus =
+ | "NOT_ASSOCIATED"
+ | "NOT_SEEN"
+ | "DISCARDED"
+ | "SEEN"
+ | "DISAPPROVED"
+ | "HIRED";
+
+// ── API DTOs ──
+
+export interface Vacancy {
+ id: string;
+ name: string;
+ description: string;
+ numbersVacancies: number;
+ area: VacancyArea | string;
+ shift: VacancyShift | string;
+ park: string;
+ section: string;
+}
+
+export interface VacancyInput {
+ name: string;
+ description: string;
+ numbersVacancies: number;
+ area: VacancyArea;
+ shift: VacancyShift;
+ // placeId rather than location string — the backend resolves park/section from the Place entity.
+ placeId: string;
+ skillIds?: string[];
+}
+
+export interface VacancyUpdateInput {
+ name: string;
+ description: string;
+ numbersVacancies?: number;
+ area: VacancyArea;
+ shift: VacancyShift;
+ placeId: string;
+}
+
+export interface Student {
+ id: string;
+ name: string;
+ email: string;
+ age: number;
+ averageGrade?: number;
+ acronym: string;
+ course: string;
+ statusStudentInterview: InterviewStatus | string;
+ hasSeenEmail: boolean;
+ statusStudent: "ENROLLED" | "FIRED" | "LEFT" | string;
+}
+
+export interface Skill {
+ id: string;
+ name: string;
+ // TECHNICAL and SOCIOEMOTIONAL are the two current categories; string allows
+ // new types without a breaking schema change.
+ skillType: "TECHNICAL" | "SOCIOEMOTIONAL" | string;
+ grade?: number;
+ studentName: string;
+}
+
+export interface Place {
+ id: string;
+ placeName: string;
+ park: string;
+ section: string;
+}
+
+export interface PlaceInput {
+ placeName: string;
+ // Only two parks currently supported by the backend.
+ park: "WEG_I" | "WEG_II";
+ section: string;
+}
+
+export interface Manager {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+ section: string;
+}
+
+export type ManagerSection = "IT";
+
+export interface ManagerInput {
+ name: string;
+ username: string;
+ email: string;
+ password: string;
+ section: ManagerSection;
+}
+
+export interface Coordinator {
+ id: string;
+ name: string;
+ username: string;
+ email: string;
+}
+
+export interface CoordinatorInput {
+ name: string;
+ username: string;
+ email: string;
+ password: string;
+}
+
+export interface Interview {
+ id: string;
+ interviewerName: string;
+ dateTime: string;
+ park: string;
+ section: string;
+ nameStudent: string;
+ nameManager: string;
+ shift: string;
+}
+
+export interface InterviewInput {
+ interviewerName: string;
+ // ISO 8601 datetime string expected by the backend — no timezone conversion is done here.
+ dateTime: string;
+ placeId: string;
+ studentId: string;
+ managerId: string;
+ vacancyId: string;
+}
+
+// ── Display label maps ──
+// Kept outside components so they can be shared without re-importing component modules.
+
+export const areaLabels: Record = {
+ IT: "Tecnologia da Informação",
+ MAINTENANCE: "Manutenção",
+ TOOLING: "Ferramentaria",
+ CHEMISTRY: "Química",
+};
+
+export const shiftLabels: Record = {
+ FIRST: "Primeiro turno",
+ SECOND: "Segundo turno",
+ THIRD: "Terceiro turno",
+ FLEXIBLE_SHIFT: "Turno flexível",
+};
+
+export const interviewStatusLabels: Record = {
+ NOT_ASSOCIATED: "Não associado",
+ NOT_SEEN: "Convite não visualizado",
+ DISCARDED: "Descartado",
+ // SEEN means the candidate has viewed the invite and is available to interview.
+ SEEN: "Disponível",
+ DISAPPROVED: "Reprovado",
+ HIRED: "Contratado",
+};
+
+// ── API functions ──
+// IDs are always encodeURIComponent'd to handle UUIDs safely in path segments.
+
+const vacancySeed: Vacancy[] = mockVacancies.map((item) => ({
+ id: item.id,
+ name: item.title,
+ description: `${item.department} - ${item.location}`,
+ numbersVacancies: item.totalSpots,
+ area: 'IT',
+ shift: 'FIRST',
+ park: item.location,
+ section: item.department,
+}));
+
+const placeSeed: Place[] = mockPlaces.map((item) => ({
+ id: item.id,
+ placeName: item.name,
+ park: item.id === 'plc-2' ? 'WEG_II' : 'WEG_I',
+ section: item.code,
+}));
+
+const studentSeed: Student[] = mockStudents.map((item) => ({
+ id: item.id,
+ name: item.name,
+ email: item.email,
+ age: 18,
+ averageGrade: item.performanceGrade,
+ acronym: item.registration,
+ course: item.courseName,
+ statusStudentInterview: 'NOT_ASSOCIATED',
+ hasSeenEmail: false,
+ statusStudent: 'ENROLLED',
+}));
+
+const interviewSeed: Interview[] = mockInterviews.map((item) => ({
+ id: item.id,
+ interviewerName: item.interviewerName,
+ dateTime: `${item.scheduledDate}T${item.scheduledTime}:00`,
+ park: 'WEG I',
+ section: item.vacancyTitle,
+ nameStudent: item.candidateName,
+ nameManager: item.interviewerName,
+ shift: item.scheduledTime,
+}));
+
+const managerSeed: Manager[] = mockUsers
+ .filter((item) => item.role === 'MANAGER')
+ .map((item) => ({
+ id: item.id,
+ name: item.name,
+ username: item.email.split('@')[0],
+ email: item.email,
+ section: 'IT',
+ }));
+
+const coordinatorSeed: Coordinator[] = mockUsers
+ .filter((item) => item.role === 'COORDINATOR')
+ .map((item) => ({
+ id: item.id,
+ name: item.name,
+ username: item.email.split('@')[0],
+ email: item.email,
+ }));
+
+export function getVacancies() {
+ return apiFirst(
+ () => apiFetchCollection('/vacancy/find/all'),
+ () => readCollection('manager-vacancies', vacancySeed)
+ );
+}
+
+export interface VacancyRequirement {
+ id: string;
+ name: string;
+ level: number;
+ priority: boolean;
+ type: 'TECHNICAL' | 'SOCIOEMOTIONAL';
+}
+
+export interface UserUpdateInput {
+ name: string;
+ email: string;
+ username?: string;
+ section?: ManagerSection;
+}
+
+export function getVacancy(id: string) {
+ return apiFirst(
+ () => apiFetch(`/vacancy/find/id/${encodeURIComponent(id)}`),
+ () => {
+ const vacancy = readCollection('manager-vacancies', vacancySeed).find((item) => item.id === id);
+ if (!vacancy) throw new Error('Vaga não encontrada.');
+ return vacancy;
+ }
+ );
+}
+
+export function createVacancy(input: VacancyInput) {
+ return apiFirst(
+ () => apiFetch('/vacancy/create', {
+ method: 'POST',
+ // The OpenAPI schema marks skillIds as optional, but the current service
+ // expects a non-null collection and rejects an omitted field with 400.
+ body: JSON.stringify({ ...input, skillIds: input.skillIds ?? [] }),
+ }),
+ () => {
+ const places = readCollection('manager-places', placeSeed);
+ const place = places.find((item) => item.id === input.placeId);
+ const created: Vacancy = {
+ id: createLocalId('vac'), name: input.name, description: input.description,
+ numbersVacancies: input.numbersVacancies, area: input.area, shift: input.shift,
+ park: place?.park ?? '', section: place?.section ?? '',
+ };
+ writeCollection('manager-vacancies', [...readCollection('manager-vacancies', vacancySeed), created]);
+ return created;
+ }
+ );
+}
+
+export function updateVacancy(id: string, input: VacancyUpdateInput) {
+ return apiFirst(
+ () => apiFetch(`/vacancy/update/${encodeURIComponent(id)}`, {
+ method: 'PATCH', body: JSON.stringify(input),
+ }),
+ () => {
+ const places = readCollection('manager-places', placeSeed);
+ const place = places.find((item) => item.id === input.placeId);
+ let updated: Vacancy | undefined;
+ const next = readCollection('manager-vacancies', vacancySeed).map((item) => {
+ if (item.id !== id) return item;
+ updated = { ...item, ...input, park: place?.park ?? item.park, section: place?.section ?? item.section };
+ return updated;
+ });
+ if (!updated) throw new Error('Vaga não encontrada.');
+ writeCollection('manager-vacancies', next);
+ return updated;
+ }
+ );
+}
+
+export function deleteVacancy(id: string) {
+ return apiFirst(
+ () => apiFetch(`/vacancy/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => {
+ const current = readCollection('manager-vacancies', vacancySeed);
+ writeCollection('manager-vacancies', current.filter((item) => item.id !== id));
+ return {};
+ }
+ );
+}
+
+export function getVacancyRequirements(vacancyId: string) {
+ return Promise.resolve(
+ readCollection(`vacancy-requirements-${vacancyId}`, [])
+ );
+}
+
+export function saveVacancyRequirements(vacancyId: string, requirements: VacancyRequirement[]) {
+ return Promise.resolve(
+ writeCollection(`vacancy-requirements-${vacancyId}`, requirements)
+ );
+}
+
+export function getStudents() {
+ return apiFirst(
+ () => apiFetchCollection('/student/find/all'),
+ () => readCollection('manager-students', studentSeed)
+ );
+}
+
+export function getStudent(id: string) {
+ return apiFirst(
+ () => apiFetch(`/student/find/id/${encodeURIComponent(id)}`),
+ () => {
+ const student = readCollection('manager-students', studentSeed).find((item) => item.id === id);
+ if (!student) throw new Error('Aluno não encontrado.');
+ return student;
+ }
+ );
+}
+
+// Only the interview status is patchable here — other student fields are managed
+// through separate coordinator-facing endpoints not exposed in this module.
+export function updateStudentInterviewStatus(
+ id: string,
+ statusStudentInterview: InterviewStatus
+) {
+ return apiFirst(
+ () => apiFetch(`/student/update/${encodeURIComponent(id)}`, {
+ method: 'PATCH', body: JSON.stringify({ statusStudentInterview }),
+ }),
+ () => {
+ let updated: Student | undefined;
+ const next = readCollection('manager-students', studentSeed).map((item) => {
+ if (item.id !== id) return item;
+ updated = { ...item, statusStudentInterview };
+ return updated;
+ });
+ if (!updated) throw new Error('Aluno não encontrado.');
+ writeCollection('manager-students', next);
+ return updated;
+ }
+ );
+}
+
+// studentName filter is applied server-side; no client-side filtering fallback.
+export function getSkills(studentName?: string) {
+ const endpoint = studentName
+ ? `/skill/search?studentName=${encodeURIComponent(studentName)}`
+ : '/skill/find/all';
+ return apiFirst(
+ () => apiFetchCollection(endpoint),
+ () => {
+ const skills = readCollection('manager-skills', []);
+ return studentName ? skills.filter((item) => item.studentName === studentName) : skills;
+ }
+ );
+}
+
+export function getPlaces() {
+ return apiFirst(
+ () => apiFetchCollection('/place/find/all'),
+ () => readCollection('manager-places', placeSeed)
+ );
+}
+
+export function createPlace(input: PlaceInput) {
+ return apiFirst(
+ () => apiFetch('/place/create', { method: 'POST', body: JSON.stringify(input) }),
+ () => {
+ const created: Place = { id: createLocalId('plc'), ...input };
+ writeCollection('manager-places', [...readCollection('manager-places', placeSeed), created]);
+ return created;
+ }
+ );
+}
+
+export function updatePlace(id: string, input: PlaceInput) {
+ return apiFirst(
+ () => apiFetch(`/place/update/${encodeURIComponent(id)}`, {
+ method: 'PATCH', body: JSON.stringify(input),
+ }),
+ () => {
+ const updated: Place = { id, ...input };
+ const next = readCollection('manager-places', placeSeed)
+ .map((item) => item.id === id ? updated : item);
+ writeCollection('manager-places', next);
+ return updated;
+ }
+ );
+}
+
+export function deletePlace(id: string) {
+ return apiFirst(
+ () => apiFetch(`/place/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => {
+ const current = readCollection('manager-places', placeSeed);
+ writeCollection('manager-places', current.filter((item) => item.id !== id));
+ return {};
+ }
+ );
+}
+
+export function getManagers() {
+ return apiFirst(
+ () => apiFetchCollection('/manager/find/all'),
+ () => readCollection('managers', managerSeed)
+ );
+}
+
+export function getCoordinators() {
+ return apiFirst(
+ () => apiFetchCollection('/coordinator/find/all'),
+ () => readCollection('coordinators', coordinatorSeed)
+ );
+}
+
+/**
+ * Creates a manager account. The backend restricts this endpoint to ADMIN sessions.
+ */
+export function createManager(input: ManagerInput) {
+ return apiFirst(
+ () => apiFetch('/manager/create', { method: 'POST', body: JSON.stringify(input) }),
+ () => {
+ const created: Manager = {
+ id: createLocalId('mgr'), name: input.name, username: input.username,
+ email: input.email, section: input.section,
+ };
+ writeCollection('managers', [...readCollection('managers', managerSeed), created]);
+ return created;
+ },
+ { neverFallbackStatuses: [400, 401, 403, 404, 409, 422] }
+ );
+}
+
+/**
+ * Creates a coordinator account. The backend restricts this endpoint to ADMIN sessions.
+ */
+export function createCoordinator(input: CoordinatorInput) {
+ return apiFirst(
+ () => apiFetch('/coordinator/create', { method: 'POST', body: JSON.stringify(input) }),
+ () => {
+ const created: Coordinator = {
+ id: createLocalId('crd'), name: input.name,
+ username: input.username, email: input.email,
+ };
+ writeCollection('coordinators', [...readCollection('coordinators', coordinatorSeed), created]);
+ return created;
+ },
+ { neverFallbackStatuses: [400, 401, 403, 404, 409, 422] }
+ );
+}
+
+export function updateManager(id: string, input: UserUpdateInput) {
+ return apiFirst(
+ () => apiFetch(`/manager/update/${encodeURIComponent(id)}`, {
+ method: 'PATCH', body: JSON.stringify({ name: input.name, section: input.section }),
+ }),
+ () => {
+ let updated: Manager | undefined;
+ const next = readCollection('managers', managerSeed).map((item) => {
+ if (item.id !== id) return item;
+ updated = { ...item, ...input, section: input.section ?? item.section };
+ return updated;
+ });
+ if (!updated) throw new Error('Gestor não encontrado.');
+ writeCollection('managers', next);
+ return updated;
+ }
+ );
+}
+
+export function deleteManager(id: string) {
+ return apiFirst(
+ () => apiFetch(`/manager/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => {
+ writeCollection('managers', readCollection('managers', managerSeed).filter((item) => item.id !== id));
+ return {};
+ }
+ );
+}
+
+export function updateCoordinator(id: string, input: UserUpdateInput) {
+ return apiFirst(
+ () => apiFetch(`/coordinator/update/${encodeURIComponent(id)}`, {
+ method: 'PATCH', body: JSON.stringify({ name: input.name }),
+ }),
+ () => {
+ let updated: Coordinator | undefined;
+ const next = readCollection('coordinators', coordinatorSeed).map((item) => {
+ if (item.id !== id) return item;
+ updated = { ...item, name: input.name, email: input.email, username: input.username ?? item.username };
+ return updated;
+ });
+ if (!updated) throw new Error('Coordenador não encontrado.');
+ writeCollection('coordinators', next);
+ return updated;
+ }
+ );
+}
+
+export function deleteCoordinator(id: string) {
+ return apiFirst(
+ () => apiFetch(`/coordinator/delete/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ () => {
+ writeCollection('coordinators', readCollection('coordinators', coordinatorSeed).filter((item) => item.id !== id));
+ return {};
+ }
+ );
+}
+
+export function getInterviews() {
+ return apiFirst(
+ () => apiFetchCollection('/interview/find/all'),
+ () => readCollection('manager-interviews', interviewSeed)
+ );
+}
+
+export function createInterview(input: InterviewInput) {
+ return apiFirst(
+ () => apiFetch('/interview/create', { method: 'POST', body: JSON.stringify(input) }),
+ () => {
+ const student = readCollection('manager-students', studentSeed).find((item) => item.id === input.studentId);
+ const place = readCollection('manager-places', placeSeed).find((item) => item.id === input.placeId);
+ const created: Interview = {
+ id: createLocalId('int'), interviewerName: input.interviewerName,
+ dateTime: input.dateTime, park: place?.park ?? '', section: place?.section ?? '',
+ nameStudent: student?.name ?? 'Aluno', nameManager: input.interviewerName,
+ shift: new Date(input.dateTime).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
+ };
+ writeCollection('manager-interviews', [...readCollection('manager-interviews', interviewSeed), created]);
+ return created;
+ }
+ );
+}
+
+/**
+ * Triggers the backend to send an invitation email for a scheduled interview.
+ * Email is passed as a query param because the endpoint does not accept a body.
+ */
+export function sendInterviewEmail(interviewId: string, email: string) {
+ return apiFirst(
+ () => apiFetch(
+ `/manager/interview/sendEmail/${encodeURIComponent(interviewId)}?email=${encodeURIComponent(email)}`,
+ { method: 'POST' }
+ ),
+ () => `Convite registrado para ${email}`
+ );
+}
diff --git a/my-app/src/lib/mock-auth.ts b/my-app/src/lib/mock-auth.ts
new file mode 100644
index 0000000..6f19427
--- /dev/null
+++ b/my-app/src/lib/mock-auth.ts
@@ -0,0 +1,80 @@
+import type { UserRole } from "@/types";
+
+export type MockAuthenticatedUser = {
+ id: string;
+ name: string;
+ username: string;
+ role: UserRole;
+};
+
+/**
+ * Fallback credentials for navigating the frontend while the API is down.
+ * NEXT_PUBLIC_* variables are visible in the browser bundle — this access path
+ * must never protect real data and must not replace backend auth/authz.
+ */
+export const MOCK_AUTH_CREDENTIALS = {
+ username: process.env.NEXT_PUBLIC_MOCK_AUTH_USERNAME || "admin.mock",
+ password: process.env.NEXT_PUBLIC_MOCK_AUTH_PASSWORD || "Mock@123456789!",
+} as const;
+
+/**
+ * Mock mode is on by default in development so new contributors don't need
+ * a running backend to start. In production it requires an explicit opt-in
+ * and a new deploy — setting the env var at runtime alone is not enough
+ * because Next.js bakes NEXT_PUBLIC_* values at build time.
+ */
+export const IS_MOCK_AUTH_ENABLED =
+ process.env.NEXT_PUBLIC_ENABLE_MOCK_AUTH === "true" ||
+ (process.env.NODE_ENV === "development" &&
+ process.env.NEXT_PUBLIC_ENABLE_MOCK_AUTH !== "false");
+
+const MOCK_ADMIN: MockAuthenticatedUser = {
+ id: "mock-admin-1",
+ name: "Administrador Mock",
+ username: MOCK_AUTH_CREDENTIALS.username,
+ role: "ADMIN",
+};
+
+/** Validates only the contingency user's credentials — real users never go through this path. */
+export function authenticateMockUser(
+ username: string,
+ password: string
+): MockAuthenticatedUser | null {
+ if (!IS_MOCK_AUTH_ENABLED) return null;
+
+ const credentialsMatch =
+ username === MOCK_AUTH_CREDENTIALS.username &&
+ password === MOCK_AUTH_CREDENTIALS.password;
+
+ return credentialsMatch ? MOCK_ADMIN : null;
+}
+
+function encodeBase64Url(value: object): string {
+ return btoa(JSON.stringify(value))
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+}
+
+/**
+ * Produces a token that satisfies the middleware's shape/expiry checks but is
+ * deliberately invalid for the backend. The third segment (`mock-session-not-valid-for-backend`)
+ * is not a real HMAC signature, so the backend will reject it if it ever reaches there —
+ * preventing mock sessions from being mistaken for real ones.
+ *
+ * TTL is 8 hours to match a typical working day without requiring re-login.
+ */
+export function createMockSessionToken(user: MockAuthenticatedUser): string {
+ const issuedAt = Math.floor(Date.now() / 1000);
+ const header = encodeBase64Url({ alg: "none", typ: "JWT" });
+ const payload = encodeBase64Url({
+ sub: user.id,
+ username: user.username,
+ role: user.role,
+ iat: issuedAt,
+ exp: issuedAt + 60 * 60 * 8,
+ mock: true,
+ });
+
+ return `${header}.${payload}.mock-session-not-valid-for-backend`;
+}
diff --git a/my-app/src/lib/mock-data.ts b/my-app/src/lib/mock-data.ts
new file mode 100644
index 0000000..81db900
--- /dev/null
+++ b/my-app/src/lib/mock-data.ts
@@ -0,0 +1,256 @@
+import type {
+ UserDTO,
+ PlaceDTO,
+ CourseDTO,
+ ClassDTO,
+ VacancyDTO,
+ InterviewDTO,
+ StudentDTO,
+ NotificationTimelineDTO,
+} from "@/types";
+
+// ── Static mock data used while the backend is unavailable ──
+//
+// These records intentionally reflect real-world scenarios so that the main
+// UI states can be exercised without needing a seeded database.
+
+export const mockUsers: UserDTO[] = [
+ {
+ id: "usr-1",
+ name: "Ana Silva",
+ email: "ana.silva@weg.net",
+ role: "ADMIN",
+ active: true,
+ },
+ {
+ id: "usr-2",
+ name: "Carlos Eduardo",
+ email: "carlos.eduardo@weg.net",
+ role: "COORDINATOR",
+ active: true,
+ },
+ {
+ // Inactive user — exercises the disabled-account UI path.
+ id: "usr-5",
+ name: "Fernanda Souza",
+ email: "fernanda.souza@weg.net",
+ role: "COORDINATOR",
+ active: false,
+ },
+];
+
+export const mockPlaces: PlaceDTO[] = [
+ {
+ id: "plc-1",
+ name: "Unidade Fabril 1 - Jaraguá do Sul",
+ code: "UF-01",
+ description: "Planta principal de motores de alta tensão",
+ city: "Jaraguá do Sul",
+ state: "SC",
+ status: "ACTIVE",
+ },
+ {
+ id: "plc-2",
+ name: "Unidade Fabril 2 - Guaramirim",
+ code: "UF-02",
+ description: "Planta de componentes eletroeletrônicos",
+ city: "Guaramirim",
+ state: "SC",
+ status: "ACTIVE",
+ },
+ {
+ id: "plc-3",
+ name: "Centro de Treinamento Técnico",
+ code: "CTT-01",
+ description: "Laboratórios e salas de aula práticas",
+ city: "Jaraguá do Sul",
+ state: "SC",
+ status: "ACTIVE",
+ },
+];
+
+export const mockCourses: CourseDTO[] = [
+ {
+ id: "crs-1",
+ name: "Técnico em Eletromecânica",
+ code: "TEL-2024",
+ coordinatorName: "Carlos Eduardo",
+ totalStudents: 48,
+ status: "ACTIVE",
+ },
+ {
+ id: "crs-2",
+ name: "Automação Industrial",
+ code: "AUT-2024",
+ coordinatorName: "Carlos Eduardo",
+ totalStudents: 36,
+ status: "ACTIVE",
+ },
+ {
+ // COMPLETED course — exercises the read-only / archived UI state.
+ id: "crs-3",
+ name: "Usinagem CNC",
+ code: "CNC-2024",
+ coordinatorName: "Fernanda Souza",
+ totalStudents: 24,
+ status: "COMPLETED",
+ },
+];
+
+export const mockClasses: ClassDTO[] = [
+ {
+ id: "cls-1",
+ name: "Turma A - Eletromecânica",
+ code: "TEL-A",
+ courseName: "Técnico em Eletromecânica",
+ period: "Matutino",
+ totalStudents: 24,
+ maxStudents: 25,
+ status: "IN_PROGRESS",
+ },
+ {
+ id: "cls-2",
+ name: "Turma B - Eletromecânica",
+ code: "TEL-B",
+ courseName: "Técnico em Eletromecânica",
+ period: "Vespertino",
+ totalStudents: 24,
+ maxStudents: 25,
+ status: "IN_PROGRESS",
+ },
+ {
+ // PLANNED class — exercises the "not started yet" UI path.
+ id: "cls-3",
+ name: "Turma A - Automação",
+ code: "AUT-A",
+ courseName: "Automação Industrial",
+ period: "Noturno",
+ totalStudents: 36,
+ maxStudents: 40,
+ status: "PLANNED",
+ },
+];
+
+export const mockVacancies: VacancyDTO[] = [
+ {
+ id: "vac-1",
+ title: "Aprendiz de Montagem Elétrica",
+ department: "Produção de Motores",
+ location: "Unidade Fabril 1",
+ totalSpots: 10,
+ filledSpots: 8,
+ status: "OPEN",
+ },
+ {
+ // CLOSED vacancy — exercises the terminal/read-only badge path.
+ id: "vac-2",
+ title: "Técnico em Manutenção Preventiva",
+ department: "Manutenção Geral",
+ location: "Unidade Fabril 2",
+ totalSpots: 5,
+ filledSpots: 5,
+ status: "CLOSED",
+ },
+ {
+ // URGENT vacancy — exercises the warning badge; low filledSpots is intentional.
+ id: "vac-3",
+ title: "Operador CNC Aprendiz",
+ department: "Usinagem",
+ location: "Unidade Fabril 1",
+ totalSpots: 4,
+ filledSpots: 1,
+ status: "URGENT",
+ },
+];
+
+export const mockInterviews: InterviewDTO[] = [
+ {
+ id: "int-1",
+ candidateName: "Gabriel Santos",
+ candidateEmail: "gabriel.santos@email.com",
+ vacancyTitle: "Aprendiz de Montagem Elétrica",
+ scheduledDate: "2026-07-25",
+ scheduledTime: "09:00",
+ interviewerName: "Mariana Costa",
+ status: "SCHEDULED",
+ },
+ {
+ id: "int-2",
+ candidateName: "Beatriz Lima",
+ candidateEmail: "beatriz.lima@email.com",
+ vacancyTitle: "Operador CNC Aprendiz",
+ scheduledDate: "2026-07-24",
+ scheduledTime: "14:30",
+ interviewerName: "Carlos Eduardo",
+ status: "APPROVED",
+ },
+ {
+ // REJECTED interview — exercises the terminal/failure state.
+ id: "int-3",
+ candidateName: "Matheus Rocha",
+ candidateEmail: "matheus.rocha@email.com",
+ vacancyTitle: "Técnico em Manutenção Preventiva",
+ scheduledDate: "2026-07-22",
+ scheduledTime: "11:00",
+ interviewerName: "Mariana Costa",
+ status: "REJECTED",
+ },
+];
+
+export const mockStudents: StudentDTO[] = [
+ {
+ id: "std-1",
+ name: "Lucas Silva Santos",
+ registration: "2024001",
+ email: "lucas.santos@estudante.weg.net",
+ courseName: "Técnico em Eletromecânica",
+ className: "Turma A - Eletromecânica",
+ status: "ACTIVE",
+ attendanceRate: 96.5,
+ performanceGrade: 9.2,
+ },
+ {
+ id: "std-2",
+ name: "Juliana Mendes",
+ registration: "2024002",
+ email: "juliana.mendes@estudante.weg.net",
+ courseName: "Automação Industrial",
+ className: "Turma A - Automação",
+ status: "ACTIVE",
+ attendanceRate: 92.0,
+ performanceGrade: 8.7,
+ },
+ {
+ id: "std-3",
+ name: "Rafael Oliveira",
+ registration: "2024003",
+ email: "rafael.oliveira@estudante.weg.net",
+ courseName: "Técnico em Eletromecânica",
+ className: "Turma B - Eletromecânica",
+ status: "ACTIVE",
+ attendanceRate: 88.0,
+ performanceGrade: 7.9,
+ },
+];
+
+export const mockTimeline: NotificationTimelineDTO[] = [
+ {
+ id: "tml-2",
+ studentId: "std-1",
+ title: "Entrevista de Acompanhamento Agendada",
+ description: "Reunião de avaliação de desempenho com a supervisão.",
+ date: "18/07/2026 às 09:00",
+ type: "INTERVIEW",
+ status: "info",
+ },
+ {
+ // WARNING entry — exercises the amber/caution timeline variant.
+ id: "tml-3",
+ studentId: "std-1",
+ title: "Alerta de Frequência",
+ description: "Frequência no limite mínimo estipulado para o módulo de Automação.",
+ date: "10/07/2026 às 16:15",
+ type: "WARNING",
+ status: "warning",
+ },
+];
diff --git a/my-app/src/lib/offline-store.ts b/my-app/src/lib/offline-store.ts
new file mode 100644
index 0000000..e2c1b1f
--- /dev/null
+++ b/my-app/src/lib/offline-store.ts
@@ -0,0 +1,107 @@
+const STORAGE_PREFIX = 'quick-transfer:';
+const OFFLINE_EVENT = 'quick-transfer:offline';
+
+export interface OfflineEventDetail {
+ active: boolean;
+ message: string;
+}
+
+function canUseStorage() {
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
+}
+
+function clone(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T;
+}
+
+export function readCollection(key: string, seed: readonly T[]): T[] {
+ if (!canUseStorage()) return clone([...seed]);
+ const storageKey = `${STORAGE_PREFIX}${key}`;
+ const saved = window.localStorage.getItem(storageKey);
+ if (!saved) {
+ const initial = clone([...seed]);
+ window.localStorage.setItem(storageKey, JSON.stringify(initial));
+ return initial;
+ }
+ try {
+ const parsed: unknown = JSON.parse(saved);
+ return Array.isArray(parsed) ? (parsed as T[]) : clone([...seed]);
+ } catch {
+ window.localStorage.removeItem(storageKey);
+ return clone([...seed]);
+ }
+}
+
+export function writeCollection(key: string, data: readonly T[]): T[] {
+ const next = clone([...data]);
+ if (canUseStorage()) {
+ window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(next));
+ }
+ return next;
+}
+
+export function createLocalId(prefix: string) {
+ const randomPart =
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ return `${prefix}-${randomPart}`;
+}
+
+export function notifyOfflineFallback(message = 'API offline. Dados salvos localmente.') {
+ if (typeof window === 'undefined') return;
+ const detail: OfflineEventDetail = { active: true, message };
+ window.sessionStorage.setItem(OFFLINE_EVENT, JSON.stringify(detail));
+ window.dispatchEvent(new CustomEvent(OFFLINE_EVENT, { detail }));
+}
+
+export function notifyApiOnline() {
+ if (typeof window === 'undefined') return;
+ const detail: OfflineEventDetail = { active: false, message: 'API online.' };
+ window.sessionStorage.removeItem(OFFLINE_EVENT);
+ window.dispatchEvent(new CustomEvent(OFFLINE_EVENT, { detail }));
+}
+
+export function subscribeToOfflineStatus(listener: (detail: OfflineEventDetail) => void) {
+ if (typeof window === 'undefined') return () => undefined;
+ const saved = window.sessionStorage.getItem(OFFLINE_EVENT);
+ if (saved) {
+ try { listener(JSON.parse(saved) as OfflineEventDetail); } catch { window.sessionStorage.removeItem(OFFLINE_EVENT); }
+ }
+ const handler = (event: Event) => {
+ listener((event as CustomEvent).detail);
+ };
+ window.addEventListener(OFFLINE_EVENT, handler);
+ return () => window.removeEventListener(OFFLINE_EVENT, handler);
+}
+
+export interface ApiFirstOptions {
+ neverFallbackStatuses?: number[];
+}
+
+function isMockSession() {
+ if (typeof document === 'undefined') return false;
+ return document.cookie.includes('mock-session-not-valid-for-backend');
+}
+
+export async function apiFirst(
+ remote: () => Promise,
+ local: () => T | Promise,
+ _options?: ApiFirstOptions
+) {
+ void _options;
+ if (isMockSession()) {
+ notifyOfflineFallback('Sessão de demonstração: dados salvos somente neste navegador.');
+ return local();
+ }
+ try {
+ const result = await remote();
+ notifyApiOnline();
+ return result;
+ } catch (error) {
+ // Real authenticated sessions must never turn authorization, validation,
+ // not-found, or server failures into apparently successful local writes.
+ // Local data is reserved exclusively for the explicit mock session above.
+ throw error;
+ }
+}
diff --git a/my-app/src/lib/utils.ts b/my-app/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/my-app/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/my-app/src/proxy.ts b/my-app/src/proxy.ts
new file mode 100644
index 0000000..c9e709a
--- /dev/null
+++ b/my-app/src/proxy.ts
@@ -0,0 +1,102 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+import {
+ AUTH_COOKIE_NAME,
+ getRedirectPathByRole,
+ isJwtFresh,
+ isRouteAllowedForRole,
+ ROLE_COOKIE_NAME,
+} from "@/lib/auth";
+
+// Both cookies are always deleted together — leaving a stale role cookie
+// without a session token would cause the sidebar to render the wrong nav
+// on the next visit before the proxy runs.
+function redirectToLogin(request: NextRequest) {
+ const response = NextResponse.redirect(new URL("/login", request.url));
+ response.cookies.delete(AUTH_COOKIE_NAME);
+ response.cookies.delete(ROLE_COOKIE_NAME);
+ return response;
+}
+
+/**
+ * Route protection and RBAC proxy.
+ *
+ * Runs on every request matched by `config.matcher` (all routes except static
+ * assets and the backend proxy). Enforces two rules in order:
+ * 1. A fresh JWT must be present — otherwise redirect to /login.
+ * 2. The role cookie must allow the requested path — otherwise redirect to
+ * the role's default landing page.
+ *
+ * Signature validation is intentionally omitted here; it happens in the backend
+ * on every authenticated API call. This proxy only blocks obviously invalid
+ * or expired tokens to avoid unnecessary round-trips.
+ */
+export function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ const authToken = request.cookies.get(AUTH_COOKIE_NAME)?.value;
+ const userRole = request.cookies.get(ROLE_COOKIE_NAME)?.value;
+
+ const isLoginPage = pathname === "/login";
+ const isMockToken = authToken?.startsWith("mock-token-") || authToken === "mock-token" || authToken === "mock-jwt-token";
+ const hasFreshToken = isMockToken || isJwtFresh(authToken);
+
+ // /login must stay accessible so users with expired sessions can re-authenticate.
+ if (isLoginPage) {
+ if (hasFreshToken) {
+ const landingPath = getRedirectPathByRole(userRole);
+ if (landingPath !== "/login") {
+ return NextResponse.redirect(new URL(landingPath, request.url));
+ }
+ }
+
+ const response = NextResponse.next();
+ if (authToken && !hasFreshToken) {
+ response.cookies.delete(AUTH_COOKIE_NAME);
+ response.cookies.delete(ROLE_COOKIE_NAME);
+ }
+ return response;
+ }
+
+ if (!hasFreshToken) {
+ return redirectToLogin(request);
+ }
+
+ if (pathname === "/") {
+ const landingPath = getRedirectPathByRole(userRole);
+ return landingPath === "/login"
+ ? redirectToLogin(request)
+ : NextResponse.redirect(new URL(landingPath, request.url));
+ }
+
+ // RBAC: if the user tries to reach a route their role doesn't permit,
+ // redirect them to their own landing page rather than showing a 403.
+ if (!isRouteAllowedForRole(pathname, userRole)) {
+ const allowedPath = getRedirectPathByRole(userRole);
+
+ if (
+ allowedPath === pathname ||
+ (allowedPath !== "/login" && !isRouteAllowedForRole(allowedPath, userRole))
+ ) {
+ return redirectToLogin(request);
+ }
+
+ return NextResponse.redirect(new URL(allowedPath, request.url));
+ }
+
+ return NextResponse.next();
+}
+
+/**
+ * Matcher excludes:
+ * - Next.js internal asset paths (_next/static, _next/image)
+ * - favicon.ico
+ * - Public static assets (images, SVG, etc.)
+ * - /backend/* — the Next.js reverse-proxy route that forwards to Spring Boot;
+ * it must be reachable unauthenticated to handle the login POST itself.
+ */
+export const config = {
+ matcher: [
+ "/((?!_next/static|_next/image|favicon.ico|assets/|backend/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
+ ],
+};
diff --git a/my-app/src/types/index.ts b/my-app/src/types/index.ts
new file mode 100644
index 0000000..43219f4
--- /dev/null
+++ b/my-app/src/types/index.ts
@@ -0,0 +1,110 @@
+// Types reflecting the Quick Transfer backend API schemas
+// Repository: https://github.com/quick-transfer/quick-transfer-backend (branch: develop)
+
+// Perfis que autenticam e operam o sistema. Aluno é uma entidade de domínio
+// representada por StudentDTO, não um usuário com acesso à aplicação.
+export type UserRole = "ADMIN" | "COORDINATOR" | "MANAGER";
+
+export type StatusType = "success" | "danger" | "warning" | "info" | "neutral";
+
+export interface NavItem {
+ label: string;
+ href: string;
+ icon: string;
+ // Lowercase strings here, not UserRole — the sidebar normalizes them before comparing.
+ roles: ("admin" | "coordinator")[];
+}
+
+export interface NavSection {
+ title: string;
+ items: NavItem[];
+}
+
+// ── Domain DTOs ──
+
+export interface UserDTO {
+ id: string;
+ name: string;
+ email: string;
+ role: UserRole;
+ cpf?: string;
+ active: boolean;
+ avatarUrl?: string;
+}
+
+export interface PlaceDTO {
+ id: string;
+ name: string;
+ code: string;
+ description?: string;
+ city: string;
+ state: string;
+ status: "ACTIVE" | "INACTIVE";
+}
+
+export interface CourseDTO {
+ id: string;
+ name: string;
+ code: string;
+ coordinatorName: string;
+ totalStudents: number;
+ status: "ACTIVE" | "INACTIVE" | "COMPLETED";
+}
+
+export interface ClassDTO {
+ id: string;
+ name: string;
+ code: string;
+ courseName: string;
+ period: string;
+ totalStudents: number;
+ maxStudents: number;
+ status: "IN_PROGRESS" | "PLANNED" | "COMPLETED";
+}
+
+// VacancyDTO is the coordinator-facing view of a vacancy (dashboard summary).
+// The manager-facing full model lives in lib/manager-api.ts as Vacancy.
+export interface VacancyDTO {
+ id: string;
+ title: string;
+ department: string;
+ location: string;
+ totalSpots: number;
+ filledSpots: number;
+ status: "OPEN" | "CLOSED" | "URGENT";
+}
+
+export interface InterviewDTO {
+ id: string;
+ candidateName: string;
+ candidateEmail: string;
+ vacancyTitle: string;
+ scheduledDate: string;
+ scheduledTime: string;
+ interviewerName: string;
+ // APPROVED and REJECTED are terminal states; PENDING is the only actionable one.
+ status: "SCHEDULED" | "APPROVED" | "REJECTED" | "PENDING";
+}
+
+export interface StudentDTO {
+ id: string;
+ name: string;
+ registration: string;
+ email: string;
+ courseName: string;
+ className: string;
+ status: "ACTIVE" | "COMPLETED" | "PAUSED";
+ attendanceRate?: number;
+ performanceGrade?: number;
+ avatarUrl?: string;
+}
+
+export interface NotificationTimelineDTO {
+ id: string;
+ studentId: string;
+ title: string;
+ description: string;
+ date: string;
+ type: "INTERVIEW" | "WARNING" | "INFO";
+ status: StatusType;
+}