feat(i18n): localize shell and common UI
This commit is contained in:
@@ -3,6 +3,7 @@ import { Toaster } from "sonner"
|
|||||||
import Navbar from "@/components/layout/navbar"
|
import Navbar from "@/components/layout/navbar"
|
||||||
import AppSidebar from "@/components/layout/sidebar"
|
import AppSidebar from "@/components/layout/sidebar"
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
import { auth } from "@/lib/auth"
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
export default async function LayoutDashboard({
|
export default async function LayoutDashboard({
|
||||||
@@ -11,10 +12,14 @@ export default async function LayoutDashboard({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar userRole={session?.user.role} />
|
<AppSidebar
|
||||||
|
copy={dictionary.layout.sidebar}
|
||||||
|
userRole={session?.user.role}
|
||||||
|
/>
|
||||||
<main className="w-full">
|
<main className="w-full">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex-1 p-6">{children}</div>
|
<div className="flex-1 p-6">{children}</div>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
export default function ForbiddenPage() {
|
import { getI18n } from "@/i18n/server"
|
||||||
|
|
||||||
|
export default async function ForbiddenPage() {
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
const copy = dictionary.common.forbidden
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<h1>Acceso denegado</h1>
|
<h1>{copy.title}</h1>
|
||||||
<p>No tienes permisos para acceder a esta sección.</p>
|
<p>{copy.description}</p>
|
||||||
<Link href="/">Volver al inicio</Link>
|
<Link href="/">{copy.homeLink}</Link>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
import { signOut } from "@/lib/auth"
|
import { signOut } from "@/lib/auth"
|
||||||
import { SIGN_IN_URL } from "@/lib/constants"
|
import { SIGN_IN_URL } from "@/lib/constants"
|
||||||
|
|
||||||
export function SignOut() {
|
type SignOutProps = {
|
||||||
|
copy: Dictionary["layout"]["logout"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOut({ copy }: SignOutProps) {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
@@ -11,7 +16,7 @@ export function SignOut() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="submit" variant="destructive">
|
<Button type="submit" variant="destructive">
|
||||||
Sign Out
|
{copy.label}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Link from "next/link"
|
|||||||
|
|
||||||
import Search from "@/components/common/search"
|
import Search from "@/components/common/search"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -11,17 +12,21 @@ interface PageHeaderProps {
|
|||||||
data: unknown[]
|
data: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageHeader({
|
export default async function PageHeader({
|
||||||
title,
|
title,
|
||||||
link,
|
link,
|
||||||
search,
|
search,
|
||||||
data,
|
data,
|
||||||
}: PageHeaderProps) {
|
}: PageHeaderProps) {
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
return (
|
return (
|
||||||
<header className="mb-4 flex w-full flex-col gap-4 md:flex-row">
|
<header className="mb-4 flex w-full flex-col gap-4 md:flex-row">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold">{title}</h1>
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
<Search hidden={data.length === 0 && !search} />
|
<Search
|
||||||
|
copy={dictionary.common.search}
|
||||||
|
hidden={data.length === 0 && !search}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{link && (
|
{link && (
|
||||||
<div className="justify-end md:ml-auto md:flex">
|
<div className="justify-end md:ml-auto md:flex">
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
|
type PaginationCopy = Dictionary["common"]["pagination"]
|
||||||
|
|
||||||
|
type PaginationClientProps = {
|
||||||
|
copy: PaginationCopy
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationClient({ copy, totalPages }: PaginationClientProps) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const currentPage = Number(searchParams.get("page")) || 1
|
||||||
|
|
||||||
|
const createPageURL = (pageNumber: number | string) => {
|
||||||
|
const params = new URLSearchParams(searchParams)
|
||||||
|
params.set("page", pageNumber.toString())
|
||||||
|
router.push(`${pathname}?${params.toString()}`)
|
||||||
|
return `${pathname}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
let start = Math.max(1, currentPage - 1)
|
||||||
|
let end = Math.min(totalPages, currentPage + 1)
|
||||||
|
|
||||||
|
// Always try to show 3 pages if possible
|
||||||
|
if (end - start < 2) {
|
||||||
|
if (start === 1) {
|
||||||
|
end = Math.min(totalPages, start + 2)
|
||||||
|
} else if (end === totalPages) {
|
||||||
|
start = Math.max(1, end - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = []
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNumbers = getPageNumbers()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{copy.summaryPrefix} {currentPage} {copy.summarySeparator} {totalPages}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
{currentPage > 1 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => createPageURL(currentPage - 1)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{copy.previous}
|
||||||
|
</PaginationPrevious>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{pageNumbers.map((page) => (
|
||||||
|
<PaginationItem key={page}>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => createPageURL(page)}
|
||||||
|
isActive={page === currentPage}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
{currentPage < totalPages && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => createPageURL(currentPage + 1)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{copy.next}
|
||||||
|
</PaginationNext>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,97 +1,18 @@
|
|||||||
"use client"
|
import { getI18n } from "@/i18n/server"
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { PaginationClient } from "./pagination.client"
|
||||||
|
|
||||||
import {
|
export default async function PaginationButtons({
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination"
|
|
||||||
|
|
||||||
export default function PaginationButtons({
|
|
||||||
totalPages,
|
totalPages,
|
||||||
}: {
|
}: {
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const { dictionary } = await getI18n()
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
|
||||||
const currentPage = Number(searchParams.get("page")) || 1
|
|
||||||
|
|
||||||
const createPageURL = (pageNumber: number | string) => {
|
|
||||||
const params = new URLSearchParams(searchParams)
|
|
||||||
params.set("page", pageNumber.toString())
|
|
||||||
router.push(`${pathname}?${params.toString()}`)
|
|
||||||
return `${pathname}?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPageNumbers = () => {
|
|
||||||
let start = Math.max(1, currentPage - 1)
|
|
||||||
let end = Math.min(totalPages, currentPage + 1)
|
|
||||||
|
|
||||||
// Always try to show 3 pages if possible
|
|
||||||
if (end - start < 2) {
|
|
||||||
if (start === 1) {
|
|
||||||
end = Math.min(totalPages, start + 2)
|
|
||||||
} else if (end === totalPages) {
|
|
||||||
start = Math.max(1, end - 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = []
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageNumbers = getPageNumbers()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<PaginationClient
|
||||||
<div>
|
copy={dictionary.common.pagination}
|
||||||
Showing page {currentPage} of {totalPages}
|
totalPages={totalPages}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<Pagination>
|
|
||||||
<PaginationContent>
|
|
||||||
{currentPage > 1 && (
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
onClick={() => createPageURL(currentPage - 1)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</PaginationPrevious>
|
|
||||||
</PaginationItem>
|
|
||||||
)}
|
|
||||||
{pageNumbers.map((page) => (
|
|
||||||
<PaginationItem key={page}>
|
|
||||||
<PaginationLink
|
|
||||||
onClick={() => createPageURL(page)}
|
|
||||||
isActive={page === currentPage}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
))}
|
|
||||||
{currentPage < totalPages && (
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
onClick={() => createPageURL(currentPage + 1)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</PaginationNext>
|
|
||||||
</PaginationItem>
|
|
||||||
)}
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,23 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useDebouncedCallback } from "use-debounce"
|
import { useDebouncedCallback } from "use-debounce"
|
||||||
|
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
|
|
||||||
|
type SearchCopy = Dictionary["common"]["search"]
|
||||||
|
|
||||||
interface SearchProps {
|
interface SearchProps {
|
||||||
|
copy: SearchCopy
|
||||||
paramKey?: string
|
paramKey?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
[x: string]: unknown
|
[x: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Search({
|
export default function Search({
|
||||||
|
copy,
|
||||||
paramKey = "search",
|
paramKey = "search",
|
||||||
placeholder = "Search...",
|
placeholder = copy.placeholder,
|
||||||
...props
|
...props
|
||||||
}: SearchProps) {
|
}: SearchProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -73,7 +79,7 @@ export default function Search({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
role="searchbox"
|
role="searchbox"
|
||||||
aria-label="Buscar"
|
aria-label={copy.label}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -86,7 +92,7 @@ export default function Search({
|
|||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Limpiar búsqueda"
|
aria-label={copy.clearLabel}
|
||||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
import { ENVIRONMENT } from "@/lib/constants"
|
import { ENVIRONMENT } from "@/lib/constants"
|
||||||
|
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
@@ -13,35 +16,43 @@ import {
|
|||||||
} from "../ui/dropdown-menu"
|
} from "../ui/dropdown-menu"
|
||||||
import ResetButton from "./resetButton"
|
import ResetButton from "./resetButton"
|
||||||
|
|
||||||
const items = [
|
type AddMenuCopy = Dictionary["layout"]["addMenu"]
|
||||||
|
|
||||||
|
type AddMenuProps = {
|
||||||
|
copy: AddMenuCopy
|
||||||
|
resetCopy: Dictionary["layout"]["resetDatabase"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: { key: keyof AddMenuCopy; href: string }[] = [
|
||||||
{
|
{
|
||||||
name: "Category",
|
key: "category",
|
||||||
href: "/inventory/categories/new",
|
href: "/inventory/categories/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Item",
|
key: "item",
|
||||||
href: "/inventory/items/new",
|
href: "/inventory/items/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Asset",
|
key: "asset",
|
||||||
href: "/inventory/assets/new",
|
href: "/inventory/assets/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Recipient",
|
key: "recipient",
|
||||||
href: "/recipients/new",
|
href: "/recipients/new",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Assignment",
|
key: "assignment",
|
||||||
href: "/assignments/new",
|
href: "/assignments/new",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AddMenu() {
|
export default function AddMenu({ copy, resetCopy }: AddMenuProps) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" className="btn btn-primary">
|
<Button type="button" aria-label={copy.add} className="btn btn-primary">
|
||||||
<Plus />
|
<Plus />
|
||||||
|
<span className="sr-only">{copy.add}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
@@ -51,18 +62,18 @@ export default function AddMenu() {
|
|||||||
className="flex cursor-pointer items-center gap-2"
|
className="flex cursor-pointer items-center gap-2"
|
||||||
passHref
|
passHref
|
||||||
>
|
>
|
||||||
<Plus /> Import
|
<Plus /> {copy.import}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<DropdownMenuItem key={item.name} asChild>
|
<DropdownMenuItem key={item.key} asChild>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex cursor-pointer items-center gap-2"
|
className="flex cursor-pointer items-center gap-2"
|
||||||
passHref
|
passHref
|
||||||
>
|
>
|
||||||
<Plus /> {item.name}
|
<Plus /> {copy[item.key]}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -70,7 +81,7 @@ export default function AddMenu() {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<ResetButton />
|
<ResetButton copy={resetCopy} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export default async function Navbar() {
|
|||||||
<nav className="flex items-center justify-between border-b p-4">
|
<nav className="flex items-center justify-between border-b p-4">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<AddMenu />
|
<AddMenu
|
||||||
|
copy={dictionary.layout.addMenu}
|
||||||
|
resetCopy={dictionary.layout.resetDatabase}
|
||||||
|
/>
|
||||||
<LanguageSwitcher
|
<LanguageSwitcher
|
||||||
activeLocale={locale}
|
activeLocale={locale}
|
||||||
copy={dictionary.common.languageSwitcher}
|
copy={dictionary.common.languageSwitcher}
|
||||||
@@ -57,10 +60,12 @@ export default async function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
{dictionary.layout.navbar.accountLabel}
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive">
|
<DropdownMenuItem variant="destructive">
|
||||||
<SignOut />
|
<SignOut copy={dictionary.layout.logout} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { useRouter } from "next/navigation"
|
|||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
export default function ResetButton() {
|
type ResetButtonProps = {
|
||||||
|
copy: Dictionary["layout"]["resetDatabase"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResetButton({ copy }: ResetButtonProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
@@ -20,11 +25,11 @@ export default function ResetButton() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success("Database reseted successfully")
|
toast.success(copy.successToast)
|
||||||
signOut()
|
signOut()
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
} else {
|
} else {
|
||||||
toast.error("Error resetting database")
|
toast.error(copy.errorToast)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -37,7 +42,7 @@ export default function ResetButton() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="animate-spin" /> : <Trash />}
|
{loading ? <Loader2 className="animate-spin" /> : <Trash />}
|
||||||
{loading ? "Resetting..." : "Reset Database"}
|
{loading ? copy.loading : copy.idle}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,60 +22,84 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
import type { UserRole } from "@/generated/prisma/client"
|
import type { UserRole } from "@/generated/prisma/client"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
|
||||||
import { SidebarSection } from "./sidebar/sidebarSection"
|
import { SidebarSection } from "./sidebar/sidebarSection"
|
||||||
|
|
||||||
const items = [
|
type SidebarCopy = Dictionary["layout"]["sidebar"]
|
||||||
|
|
||||||
|
type SidebarLabelKey = keyof SidebarCopy
|
||||||
|
|
||||||
|
type SidebarItem =
|
||||||
|
| {
|
||||||
|
type: "item"
|
||||||
|
labelKey: SidebarLabelKey
|
||||||
|
url: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "section"
|
||||||
|
labelKey: SidebarLabelKey
|
||||||
|
url: string
|
||||||
|
icon: React.ElementType
|
||||||
|
items: { labelKey: SidebarLabelKey; url: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: SidebarItem[] = [
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
title: "Home",
|
labelKey: "home",
|
||||||
url: "/",
|
url: "/",
|
||||||
icon: Home,
|
icon: Home,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "section",
|
||||||
title: "Inventory",
|
labelKey: "inventory",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Items",
|
labelKey: "items",
|
||||||
url: "/inventory/items",
|
url: "/inventory/items",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Categories",
|
labelKey: "categories",
|
||||||
url: "/inventory/categories",
|
url: "/inventory/categories",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Assets",
|
labelKey: "assets",
|
||||||
url: "/inventory/assets",
|
url: "/inventory/assets",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
title: "Recipients",
|
labelKey: "recipients",
|
||||||
url: "/recipients",
|
url: "/recipients",
|
||||||
icon: User,
|
icon: User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
title: "Movements",
|
labelKey: "movements",
|
||||||
url: "/movements",
|
url: "/movements",
|
||||||
icon: BarChart,
|
icon: BarChart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
title: "Assignments",
|
labelKey: "assignments",
|
||||||
url: "/assignments",
|
url: "/assignments",
|
||||||
icon: Clipboard,
|
icon: Clipboard,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AppSidebar({
|
export default function AppSidebar({
|
||||||
|
copy,
|
||||||
userRole,
|
userRole,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar> & { userRole?: UserRole }) {
|
}: React.ComponentProps<typeof Sidebar> & {
|
||||||
|
copy: SidebarCopy
|
||||||
|
userRole?: UserRole
|
||||||
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const visibleItems =
|
const visibleItems =
|
||||||
userRole === "ADMIN"
|
userRole === "ADMIN"
|
||||||
@@ -83,10 +107,10 @@ export default function AppSidebar({
|
|||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
type: "item",
|
type: "item",
|
||||||
title: "Users",
|
labelKey: "users",
|
||||||
url: "/admin/users",
|
url: "/admin/users",
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
},
|
} satisfies SidebarItem,
|
||||||
]
|
]
|
||||||
: items
|
: items
|
||||||
|
|
||||||
@@ -110,11 +134,11 @@ export default function AppSidebar({
|
|||||||
: pathname.startsWith(item.url)
|
: pathname.startsWith(item.url)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={`item-${item.title}`}>
|
<SidebarMenuItem key={`item-${item.labelKey}`}>
|
||||||
<SidebarMenuButton asChild isActive={isActive}>
|
<SidebarMenuButton asChild isActive={isActive}>
|
||||||
<Link href={item.url}>
|
<Link href={item.url}>
|
||||||
<item.icon className="mr-2 h-4 w-4" />
|
<item.icon className="mr-2 h-4 w-4" />
|
||||||
<span>{item.title}</span>
|
<span>{copy[item.labelKey]}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -123,10 +147,13 @@ export default function AppSidebar({
|
|||||||
if (item.type === "section") {
|
if (item.type === "section") {
|
||||||
return (
|
return (
|
||||||
<SidebarSection
|
<SidebarSection
|
||||||
key={`section-${item.title}`}
|
key={`section-${item.labelKey}`}
|
||||||
title={item.title}
|
title={copy[item.labelKey]}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
items={item.items}
|
items={item.items.map((subItem) => ({
|
||||||
|
title: copy[subItem.labelKey],
|
||||||
|
url: subItem.url,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,56 @@ export const en = {
|
|||||||
es: "Spanish",
|
es: "Spanish",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: "Search...",
|
||||||
|
label: "Search",
|
||||||
|
clearLabel: "Clear search",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
summaryPrefix: "Showing page",
|
||||||
|
summarySeparator: "of",
|
||||||
|
previous: "Previous",
|
||||||
|
next: "Next",
|
||||||
|
},
|
||||||
|
forbidden: {
|
||||||
|
title: "Access denied",
|
||||||
|
description: "You do not have permission to access this section.",
|
||||||
|
homeLink: "Back to home",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
sidebar: {
|
||||||
|
home: "Home",
|
||||||
|
inventory: "Inventory",
|
||||||
|
items: "Items",
|
||||||
|
categories: "Categories",
|
||||||
|
assets: "Assets",
|
||||||
|
recipients: "Recipients",
|
||||||
|
movements: "Movements",
|
||||||
|
assignments: "Assignments",
|
||||||
|
users: "Users",
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
accountLabel: "My Account",
|
||||||
|
},
|
||||||
|
addMenu: {
|
||||||
|
add: "Add",
|
||||||
|
import: "Import",
|
||||||
|
category: "Category",
|
||||||
|
item: "Item",
|
||||||
|
asset: "Asset",
|
||||||
|
recipient: "Recipient",
|
||||||
|
assignment: "Assignment",
|
||||||
|
},
|
||||||
|
resetDatabase: {
|
||||||
|
idle: "Reset Database",
|
||||||
|
loading: "Resetting...",
|
||||||
|
successToast: "Database reset successfully",
|
||||||
|
errorToast: "Error resetting database",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
label: "Sign Out",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: "Sign In",
|
title: "Sign In",
|
||||||
|
|||||||
@@ -9,6 +9,56 @@ export const es = {
|
|||||||
es: "Español",
|
es: "Español",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: "Buscar...",
|
||||||
|
label: "Buscar",
|
||||||
|
clearLabel: "Limpiar búsqueda",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
summaryPrefix: "Mostrando página",
|
||||||
|
summarySeparator: "de",
|
||||||
|
previous: "Anterior",
|
||||||
|
next: "Siguiente",
|
||||||
|
},
|
||||||
|
forbidden: {
|
||||||
|
title: "Acceso denegado",
|
||||||
|
description: "No tienes permisos para acceder a esta sección.",
|
||||||
|
homeLink: "Volver al inicio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
sidebar: {
|
||||||
|
home: "Inicio",
|
||||||
|
inventory: "Inventario",
|
||||||
|
items: "Artículos",
|
||||||
|
categories: "Categorías",
|
||||||
|
assets: "Activos",
|
||||||
|
recipients: "Destinatarios",
|
||||||
|
movements: "Movimientos",
|
||||||
|
assignments: "Asignaciones",
|
||||||
|
users: "Usuarios",
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
accountLabel: "Mi cuenta",
|
||||||
|
},
|
||||||
|
addMenu: {
|
||||||
|
add: "Añadir",
|
||||||
|
import: "Importar",
|
||||||
|
category: "Categoría",
|
||||||
|
item: "Artículo",
|
||||||
|
asset: "Activo",
|
||||||
|
recipient: "Destinatario",
|
||||||
|
assignment: "Asignación",
|
||||||
|
},
|
||||||
|
resetDatabase: {
|
||||||
|
idle: "Reiniciar base de datos",
|
||||||
|
loading: "Reiniciando...",
|
||||||
|
successToast: "Base de datos reiniciada correctamente",
|
||||||
|
errorToast: "Error al reiniciar la base de datos",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
label: "Cerrar sesión",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: "Iniciar sesión",
|
title: "Iniciar sesión",
|
||||||
|
|||||||
@@ -86,8 +86,47 @@ test.describe("language switcher", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Panel de control" }),
|
page.getByRole("heading", { name: "Panel de control" }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
await expect(page.getByRole("link", { name: /Inicio/ })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: /Inventario/ }).click()
|
||||||
|
await expect(page.getByRole("link", { name: /Artículos/ })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: /Destinatarios/ }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(page.getByRole("link", { name: /Usuarios/ })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Añadir" }).click()
|
||||||
|
await expect(page.getByRole("menuitem", { name: /Importar/ })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole("menuitem", { name: /Categoría/ }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole("menuitem", { name: /Asignación/ }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
await expect(page.getByText("E2E Admin")).toBeVisible()
|
await expect(page.getByText("E2E Admin")).toBeVisible()
|
||||||
await expect(page.getByText("admin@example.test")).toBeVisible()
|
await page.getByText("E2E Admin").click()
|
||||||
|
await expect(page.getByText("Mi cuenta")).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Cerrar sesión" }),
|
||||||
|
).toBeVisible()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
await page.goto("/admin/users")
|
||||||
|
await expect(page.getByPlaceholder("Buscar...")).toBeVisible()
|
||||||
|
await expect(page.getByRole("searchbox", { name: "Buscar" })).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto("/forbidden")
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Acceso denegado" }),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByText("No tienes permisos para acceder a esta sección."),
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Volver al inicio" }),
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
await expectLocaleCookie(page, "es")
|
await expectLocaleCookie(page, "es")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,113 @@ describe("i18n dictionaries", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("provides localized shell and common copy for English and Spanish", () => {
|
||||||
|
expect(getDictionary("en").layout).toEqual({
|
||||||
|
sidebar: {
|
||||||
|
home: "Home",
|
||||||
|
inventory: "Inventory",
|
||||||
|
items: "Items",
|
||||||
|
categories: "Categories",
|
||||||
|
assets: "Assets",
|
||||||
|
recipients: "Recipients",
|
||||||
|
movements: "Movements",
|
||||||
|
assignments: "Assignments",
|
||||||
|
users: "Users",
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
accountLabel: "My Account",
|
||||||
|
},
|
||||||
|
addMenu: {
|
||||||
|
add: "Add",
|
||||||
|
import: "Import",
|
||||||
|
category: "Category",
|
||||||
|
item: "Item",
|
||||||
|
asset: "Asset",
|
||||||
|
recipient: "Recipient",
|
||||||
|
assignment: "Assignment",
|
||||||
|
},
|
||||||
|
resetDatabase: {
|
||||||
|
idle: "Reset Database",
|
||||||
|
loading: "Resetting...",
|
||||||
|
successToast: "Database reset successfully",
|
||||||
|
errorToast: "Error resetting database",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
label: "Sign Out",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("es").layout).toEqual({
|
||||||
|
sidebar: {
|
||||||
|
home: "Inicio",
|
||||||
|
inventory: "Inventario",
|
||||||
|
items: "Artículos",
|
||||||
|
categories: "Categorías",
|
||||||
|
assets: "Activos",
|
||||||
|
recipients: "Destinatarios",
|
||||||
|
movements: "Movimientos",
|
||||||
|
assignments: "Asignaciones",
|
||||||
|
users: "Usuarios",
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
accountLabel: "Mi cuenta",
|
||||||
|
},
|
||||||
|
addMenu: {
|
||||||
|
add: "Añadir",
|
||||||
|
import: "Importar",
|
||||||
|
category: "Categoría",
|
||||||
|
item: "Artículo",
|
||||||
|
asset: "Activo",
|
||||||
|
recipient: "Destinatario",
|
||||||
|
assignment: "Asignación",
|
||||||
|
},
|
||||||
|
resetDatabase: {
|
||||||
|
idle: "Reiniciar base de datos",
|
||||||
|
loading: "Reiniciando...",
|
||||||
|
successToast: "Base de datos reiniciada correctamente",
|
||||||
|
errorToast: "Error al reiniciar la base de datos",
|
||||||
|
},
|
||||||
|
logout: {
|
||||||
|
label: "Cerrar sesión",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("en").common.search).toEqual({
|
||||||
|
placeholder: "Search...",
|
||||||
|
label: "Search",
|
||||||
|
clearLabel: "Clear search",
|
||||||
|
})
|
||||||
|
expect(getDictionary("es").common.search).toEqual({
|
||||||
|
placeholder: "Buscar...",
|
||||||
|
label: "Buscar",
|
||||||
|
clearLabel: "Limpiar búsqueda",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("en").common.pagination).toEqual({
|
||||||
|
summaryPrefix: "Showing page",
|
||||||
|
summarySeparator: "of",
|
||||||
|
previous: "Previous",
|
||||||
|
next: "Next",
|
||||||
|
})
|
||||||
|
expect(getDictionary("es").common.pagination).toEqual({
|
||||||
|
summaryPrefix: "Mostrando página",
|
||||||
|
summarySeparator: "de",
|
||||||
|
previous: "Anterior",
|
||||||
|
next: "Siguiente",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getDictionary("en").common.forbidden).toEqual({
|
||||||
|
title: "Access denied",
|
||||||
|
description: "You do not have permission to access this section.",
|
||||||
|
homeLink: "Back to home",
|
||||||
|
})
|
||||||
|
expect(getDictionary("es").common.forbidden).toEqual({
|
||||||
|
title: "Acceso denegado",
|
||||||
|
description: "No tienes permisos para acceder a esta sección.",
|
||||||
|
homeLink: "Volver al inicio",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("keeps dashboard home dictionary keys aligned across locales", () => {
|
it("keeps dashboard home dictionary keys aligned across locales", () => {
|
||||||
expect(getDictionary("en").dashboardHome).toEqual({
|
expect(getDictionary("en").dashboardHome).toEqual({
|
||||||
heading: "Dashboard",
|
heading: "Dashboard",
|
||||||
|
|||||||
Reference in New Issue
Block a user