#591 - profile, users and projects

This commit is contained in:
Saeed Vaziry
2025-05-18 18:25:27 +02:00
parent edd4ba1bc2
commit 8b4d156afa
67 changed files with 1467 additions and 760 deletions

View File

@ -14,7 +14,7 @@ class CreateUser
*/
public function create(array $input): User
{
$this->validate($input);
Validator::make($input, self::rules())->validate();
/** @var User $user */
$user = User::query()->create([
@ -28,14 +28,6 @@ public function create(array $input): User
return $user;
}
/**
* @param array<string, mixed> $input
*/
private function validate(array $input): void
{
Validator::make($input, self::rules())->validate();
}
/**
* @return array<string, mixed>
*/

View File

@ -18,7 +18,6 @@ public function update(User $user, array $input): User
$user->name = $input['name'];
$user->email = $input['email'];
$user->timezone = $input['timezone'];
$user->role = $input['role'];
if (isset($input['password'])) {
@ -50,10 +49,6 @@ public static function rules(User $user): array
'email', 'max:255',
Rule::unique('users', 'email')->ignore($user->id),
],
'timezone' => [
'required',
Rule::in(timezone_identifiers_list()),
],
'role' => [
'required',
Rule::in([UserRole::ADMIN, UserRole::USER]),

View File

@ -6,13 +6,11 @@
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
@ -37,7 +35,6 @@ public function update(Request $request): RedirectResponse
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
@ -66,25 +63,6 @@ public function password(Request $request): RedirectResponse
'password' => Hash::make($validated['password']),
]);
return back();
}
#[Delete('/', name: 'profile.destroy')]
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
return to_route('profile');
}
}

View File

@ -5,6 +5,7 @@
use App\Actions\Projects\AddUser;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DeleteProject;
use App\Actions\Projects\UpdateProject;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Models\User;
@ -16,6 +17,7 @@
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
@ -30,7 +32,7 @@ public function index(): Response
return Inertia::render('projects/index', [
'projects' => ProjectResource::collection(
Project::query()->simplePaginate(config('web.pagination_size'))
Project::query()->with('users')->simplePaginate(config('web.pagination_size'))
),
]);
}
@ -50,7 +52,18 @@ public function store(Request $request): RedirectResponse
->with('success', 'Project created successfully.');
}
#[Post('switch/{project}', name: 'projects.switch')]
#[Patch('/{project}', name: 'projects.update')]
public function update(Request $request, Project $project): RedirectResponse
{
$this->authorize('update', $project);
app(UpdateProject::class)->update($project, $request->all());
return redirect()->route('projects')
->with('success', 'Project updated successfully.');
}
#[Patch('switch/{project}', name: 'projects.switch')]
public function switch(Project $project): RedirectResponse
{
$this->authorize('view', $project);
@ -70,7 +83,7 @@ public function switch(Project $project): RedirectResponse
return redirect()->route($previousRoute->getName());
}
#[Post('/{project}/users', name: 'projects.users')]
#[Post('/{project}/users', name: 'projects.users.store')]
public function storeUser(Request $request, Project $project): RedirectResponse
{
$this->authorize('update', $project);
@ -81,20 +94,11 @@ public function storeUser(Request $request, Project $project): RedirectResponse
->with('success', 'User added to project successfully.');
}
#[Delete('{project}/users', name: 'projects.users')]
public function destroyUser(Request $request, Project $project): RedirectResponse
#[Delete('{project}/users/{user}', name: 'projects.users.destroy')]
public function destroyUser(Project $project, User $user): RedirectResponse
{
$this->authorize('update', $project);
$this->validate($request, [
'user' => [
'required',
'exists:users,id',
],
]);
$user = User::query()->find($request->input('user'));
$project->users()->detach($user);
return redirect()->route('projects')

View File

@ -2,12 +2,22 @@
namespace App\Http\Controllers;
use App\Actions\User\CreateUser;
use App\Actions\User\UpdateUser;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\RouteAttributes\Attributes\Delete;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
#[Prefix('users')]
@ -15,7 +25,19 @@
class UserController extends Controller
{
#[Get('/', name: 'users')]
public function index(Request $request): ResourceCollection
public function index(): Response
{
$this->authorize('viewAny', User::class);
return Inertia::render('users/index', [
'users' => UserResource::collection(
User::query()->with('projects')->simplePaginate(config('web.pagination_size'))
),
]);
}
#[Get('/json', name: 'users.json')]
public function json(Request $request): ResourceCollection
{
$this->authorize('viewAny', User::class);
@ -33,4 +55,64 @@ public function index(Request $request): ResourceCollection
return UserResource::collection($users);
}
#[Post('/', name: 'users.store')]
public function store(Request $request): RedirectResponse
{
$this->authorize('create', User::class);
app(CreateUser::class)->create($request->all());
return to_route('users')->with('success', 'User created successfully.');
}
#[Patch('/{user}', name: 'users.update')]
public function update(Request $request, User $user): RedirectResponse
{
$this->authorize('update', $user);
app(UpdateUser::class)->update($user, $request->all());
return to_route('users')->with('success', 'User updated successfully.');
}
#[Post('/{user}/projects', name: 'users.projects.store')]
public function addToProject(Request $request, User $user): RedirectResponse
{
$this->authorize('update', $user);
$this->validate($request, [
'project' => [
'required',
Rule::exists('projects', 'id'),
],
]);
$project = Project::query()->findOrFail($request->input('project'));
$user->projects()->detach($project);
$user->projects()->attach($project);
return to_route('users')->with('success', 'Project was successfully added to user.');
}
#[Delete('/{user}/projects/{project}', name: 'users.projects.destroy')]
public function removeProject(User $user, Project $project): RedirectResponse
{
$this->authorize('update', $user);
$user->projects()->detach($project);
return to_route('users')->with('success', 'Project was successfully removed from user.');
}
#[Delete('/{user}', 'users.destroy')]
public function destroy(User $user): RedirectResponse
{
$this->authorize('delete', $user);
$user->delete();
return to_route('users')->with('success', 'User was successfully deleted.');
}
}

View File

@ -17,11 +17,11 @@ public function toArray(Request $request): array
return [
'id' => $this->id,
'name' => $this->name,
'users' => UserResource::collection($this->users),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'created_at_by_timezone' => $this->created_at_by_timezone,
'updated_at_by_timezone' => $this->updated_at_by_timezone,
'users' => UserResource::collection(
$this->whenLoaded('users')
),
];
}
}

View File

@ -24,8 +24,6 @@ public function toArray(Request $request): array
'is_remote' => $this->is_remote,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'created_at_by_timezone' => $this->created_at_by_timezone,
'updated_at_by_timezone' => $this->updated_at_by_timezone,
];
}
}

View File

@ -22,8 +22,6 @@ public function toArray(Request $request): array
'provider' => $this->provider,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'created_at_by_timezone' => $this->created_at_by_timezone,
'updated_at_by_timezone' => $this->updated_at_by_timezone,
];
}
}

View File

@ -41,8 +41,6 @@ public function toArray(Request $request): array
'status_color' => $this->getStatusColor(),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'created_at_by_timezone' => $this->created_at_by_timezone,
'updated_at_by_timezone' => $this->updated_at_by_timezone,
];
}
}

View File

@ -18,8 +18,10 @@ public function toArray(Request $request): array
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'projects' => ProjectResource::collection($this->whenLoaded('projects')),
];
}
}

10
package-lock.json generated
View File

@ -37,6 +37,7 @@
"globals": "^15.14.0",
"laravel-vite-plugin": "^1.0",
"lucide-react": "^0.475.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -6356,6 +6357,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -58,6 +58,7 @@
"globals": "^15.14.0",
"laravel-vite-plugin": "^1.0",
"lucide-react": "^0.475.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -92,21 +92,21 @@ @layer base {
}
:root {
--brand: var(--color-indigo-500);
--brand: oklch(58.5% 0.233 277.117);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary: oklch(51.1% 0.262 276.966);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: var(--color-green-500);
@ -148,8 +148,8 @@ .dark {
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(51.1% 0.262 276.966);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@ -199,10 +199,6 @@ @layer base {
}
}
/*
---break---
*/
@theme inline {
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
@ -222,6 +218,7 @@ @layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}

View File

@ -1,4 +1,4 @@
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command';
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CommandIcon, SearchIcon } from 'lucide-react';

View File

@ -1,5 +1,4 @@
import { SidebarTrigger } from '@/components/ui/sidebar';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
import { ProjectSwitch } from '@/components/project-switch';
import { SlashIcon } from 'lucide-react';
import { ServerSwitch } from '@/components/server-switch';
@ -10,19 +9,11 @@ export function AppHeader() {
<header className="bg-background -ml-1 flex h-12 shrink-0 items-center justify-between gap-2 border-b p-4 md:-ml-2">
<div className="flex items-center">
<SidebarTrigger className="-ml-1 md:hidden" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<ProjectSwitch />
</BreadcrumbItem>
<BreadcrumbSeparator>
<SlashIcon />
</BreadcrumbSeparator>
<BreadcrumbItem>
<ServerSwitch />
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center space-x-2 text-xs">
<ProjectSwitch />
<SlashIcon className="size-3" />
<ServerSwitch />
</div>
</div>
<AppCommand />
</header>

View File

@ -0,0 +1,14 @@
import { cn } from '@/lib/utils';
export default function AppLogoIconHtml({ className }: { className?: string }) {
return (
<div
className={cn(
'border-primary dark:bg-primary/60 from-primary to-primary/80 flex size-7 items-center justify-center rounded-md border bg-gradient-to-br font-bold text-white! shadow-xs dark:from-transparent dark:to-transparent',
className,
)}
>
V
</div>
);
}

View File

@ -4,7 +4,7 @@ export default function AppLogo() {
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md">
<AppLogoIcon className="size-7 rounded-sm fill-current text-white dark:text-black" />
<AppLogoIcon />
</div>
</>
);

View File

@ -1,23 +1,26 @@
'use client';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { cn } from '@/lib/utils';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
className?: string;
modal?: boolean;
}
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
export function DataTable<TData, TValue>({ columns, data, className, modal }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const extraClasses = modal && 'border-none shadow-none';
return (
<div className="rounded-md border">
<div className={cn('rounded-md border shadow-xs', className, extraClasses)}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@ -0,0 +1,9 @@
import moment from 'moment';
export default function DateTime({ date, format = 'YYYY-MM-DD hh:mm:ss', className }: { date: string; format?: string; className?: string }) {
return (
<time dateTime={date} className={className}>
{moment(date).format(format)}
</time>
);
}

View File

@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button';
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
import { useInitials } from '@/hooks/use-initials';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import CreateProject from '@/pages/projects/components/create-project';
import ProjectForm from '@/pages/projects/components/project-form';
export function ProjectSwitch() {
const page = usePage<SharedData>();
@ -26,7 +26,7 @@ export function ProjectSwitch() {
const selectedProject = auth.projects.find((project) => project.id.toString() === projectId);
if (selectedProject) {
setSelectedProject(selectedProject.id.toString());
form.post(route('projects.switch', { project: projectId, currentPath: window.location.pathname }));
form.patch(route('projects.switch', { project: projectId, currentPath: window.location.pathname }));
}
};
@ -57,14 +57,14 @@ export function ProjectSwitch() {
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<CreateProject>
<ProjectForm>
<DropdownMenuItem className="gap-0" asChild onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
<PlusIcon size={5} />
<span className="ml-2">Create new project</span>
</div>
</DropdownMenuItem>
</CreateProject>
</ProjectForm>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@ -1,155 +0,0 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -9,9 +9,10 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
default:
'shadow-lg dark:border dark:border-primary bg-primary/90 dark:bg-primary/60 text-primary-foreground dark:text-foreground/90 shadow-xs hover:bg-primary/90 dark:hover:bg-primary/80 focus-visible:ring-primary/20 dark:focus-visible:ring-primary/40',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
'border border-destructive dark:bg-destructive/60 bg-destructive/70 text-white shadow-xs hover:bg-destructive/90 dark:hover:bg-destructive/80 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',

View File

@ -3,13 +3,11 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="card" className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)} {...props} />
);
return <div data-slot="card" className={cn('bg-card text-card-foreground flex flex-col rounded-xl border shadow-xs', className)} {...props} />;
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-header" className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
return <div data-slot="card-header" className={cn('flex flex-col gap-1.5 border-b p-4', className)} {...props} />;
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
@ -21,11 +19,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
return <div data-slot="card-content" className={cn('', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />;
return <div data-slot="card-footer" className={cn('flex items-center border-t p-4', className)} {...props} />;
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export function Combobox({
items,
value,
onValueChange,
}: {
items: { value: string; label: string }[];
value: string;
onValueChange: (value: string) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-[400px] justify-between">
{value ? items.find((item) => item.value === value)?.label : 'Select item...'}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Search item..." />
<CommandList>
<CommandEmpty>No item found.</CommandEmpty>
<CommandGroup>
{open &&
items.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
value = currentValue;
onValueChange(value);
setOpen(false);
}}
>
{item.label}
<Check className={cn('ml-auto', value === item.value ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -25,7 +25,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-xs',
className,
)}
{...props}
@ -40,7 +40,7 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
@ -56,11 +56,13 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />;
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2 border-b p-4 text-center sm:text-left', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-footer" className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
return (
<div data-slot="dialog-footer" className={cn('flex flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end', className)} {...props} />
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {

View File

@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,

View File

@ -21,7 +21,7 @@ function SelectTrigger({ className, children, ...props }: React.ComponentProps<t
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1",
className,
)}
{...props}

View File

@ -1,5 +1,3 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';

View File

@ -25,7 +25,7 @@ function SheetOverlay({ className, ...props }: React.ComponentProps<typeof Sheet
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/20 backdrop-blur-xs',
className,
)}
{...props}
@ -47,7 +47,7 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
@ -69,11 +69,11 @@ function SheetContent({
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-1.5 p-4', className)} {...props} />;
return <div data-slot="sheet-header" className={cn('flex flex-col gap-1.5 border-b p-4', className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 border-t p-4', className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {

View File

@ -365,7 +365,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'cursor-pointer peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {

View File

@ -33,7 +33,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'text-foreground h-10 px-4 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
@ -45,7 +45,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn('p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
className={cn('px-4 py-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
{...props}
/>
);

View File

@ -1,43 +1,28 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
const TooltipProvider = TooltipPrimitive.Provider;
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
const Tooltip = TooltipPrimitive.Root;
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -14,7 +14,7 @@ export default function UserSelect({ onChange }: { onChange: (selectedUser: User
const [value, setValue] = useState<string>();
const fetchUsers = async () => {
const response = await axios.get(route('users', { query: query }));
const response = await axios.get(route('users.json', { query: query }));
if (response.status === 200) {
setUsers(response.data as User[]);

View File

@ -1,5 +1,5 @@
import { type BreadcrumbItem, type NavItem } from '@/types';
import { ListIcon, UserIcon } from 'lucide-react';
import { ListIcon, UserIcon, UsersIcon } from 'lucide-react';
import { ReactNode } from 'react';
import Layout from '@/layouts/app/layout';
@ -9,6 +9,11 @@ const sidebarNavItems: NavItem[] = [
href: route('profile'),
icon: UserIcon,
},
{
title: 'Users',
href: route('users'),
icon: UsersIcon,
},
{
title: 'Projects',
href: route('projects'),

View File

@ -0,0 +1,420 @@
export const timezones = [
{ value: 'Africa/Abidjan', label: 'Africa/Abidjan' },
{ value: 'Africa/Accra', label: 'Africa/Accra' },
{ value: 'Africa/Addis_Ababa', label: 'Africa/Addis_Ababa' },
{ value: 'Africa/Algiers', label: 'Africa/Algiers' },
{ value: 'Africa/Asmara', label: 'Africa/Asmara' },
{ value: 'Africa/Bamako', label: 'Africa/Bamako' },
{ value: 'Africa/Bangui', label: 'Africa/Bangui' },
{ value: 'Africa/Banjul', label: 'Africa/Banjul' },
{ value: 'Africa/Bissau', label: 'Africa/Bissau' },
{ value: 'Africa/Blantyre', label: 'Africa/Blantyre' },
{ value: 'Africa/Brazzaville', label: 'Africa/Brazzaville' },
{ value: 'Africa/Bujumbura', label: 'Africa/Bujumbura' },
{ value: 'Africa/Cairo', label: 'Africa/Cairo' },
{ value: 'Africa/Casablanca', label: 'Africa/Casablanca' },
{ value: 'Africa/Ceuta', label: 'Africa/Ceuta' },
{ value: 'Africa/Conakry', label: 'Africa/Conakry' },
{ value: 'Africa/Dakar', label: 'Africa/Dakar' },
{ value: 'Africa/Dar_es_Salaam', label: 'Africa/Dar_es_Salaam' },
{ value: 'Africa/Djibouti', label: 'Africa/Djibouti' },
{ value: 'Africa/Douala', label: 'Africa/Douala' },
{ value: 'Africa/El_Aaiun', label: 'Africa/El_Aaiun' },
{ value: 'Africa/Freetown', label: 'Africa/Freetown' },
{ value: 'Africa/Gaborone', label: 'Africa/Gaborone' },
{ value: 'Africa/Harare', label: 'Africa/Harare' },
{ value: 'Africa/Johannesburg', label: 'Africa/Johannesburg' },
{ value: 'Africa/Juba', label: 'Africa/Juba' },
{ value: 'Africa/Kampala', label: 'Africa/Kampala' },
{ value: 'Africa/Khartoum', label: 'Africa/Khartoum' },
{ value: 'Africa/Kigali', label: 'Africa/Kigali' },
{ value: 'Africa/Kinshasa', label: 'Africa/Kinshasa' },
{ value: 'Africa/Lagos', label: 'Africa/Lagos' },
{ value: 'Africa/Libreville', label: 'Africa/Libreville' },
{ value: 'Africa/Lome', label: 'Africa/Lome' },
{ value: 'Africa/Luanda', label: 'Africa/Luanda' },
{ value: 'Africa/Lubumbashi', label: 'Africa/Lubumbashi' },
{ value: 'Africa/Lusaka', label: 'Africa/Lusaka' },
{ value: 'Africa/Malabo', label: 'Africa/Malabo' },
{ value: 'Africa/Maputo', label: 'Africa/Maputo' },
{ value: 'Africa/Maseru', label: 'Africa/Maseru' },
{ value: 'Africa/Mbabane', label: 'Africa/Mbabane' },
{ value: 'Africa/Mogadishu', label: 'Africa/Mogadishu' },
{ value: 'Africa/Monrovia', label: 'Africa/Monrovia' },
{ value: 'Africa/Nairobi', label: 'Africa/Nairobi' },
{ value: 'Africa/Ndjamena', label: 'Africa/Ndjamena' },
{ value: 'Africa/Niamey', label: 'Africa/Niamey' },
{ value: 'Africa/Nouakchott', label: 'Africa/Nouakchott' },
{ value: 'Africa/Ouagadougou', label: 'Africa/Ouagadougou' },
{ value: 'Africa/Porto-Novo', label: 'Africa/Porto-Novo' },
{ value: 'Africa/Sao_Tome', label: 'Africa/Sao_Tome' },
{ value: 'Africa/Tripoli', label: 'Africa/Tripoli' },
{ value: 'Africa/Tunis', label: 'Africa/Tunis' },
{ value: 'Africa/Windhoek', label: 'Africa/Windhoek' },
{ value: 'America/Adak', label: 'America/Adak' },
{ value: 'America/Anchorage', label: 'America/Anchorage' },
{ value: 'America/Anguilla', label: 'America/Anguilla' },
{ value: 'America/Antigua', label: 'America/Antigua' },
{ value: 'America/Araguaina', label: 'America/Araguaina' },
{ value: 'America/Argentina/Buenos_Aires', label: 'America/Argentina/Buenos_Aires' },
{ value: 'America/Argentina/Catamarca', label: 'America/Argentina/Catamarca' },
{ value: 'America/Argentina/Cordoba', label: 'America/Argentina/Cordoba' },
{ value: 'America/Argentina/Jujuy', label: 'America/Argentina/Jujuy' },
{ value: 'America/Argentina/La_Rioja', label: 'America/Argentina/La_Rioja' },
{ value: 'America/Argentina/Mendoza', label: 'America/Argentina/Mendoza' },
{ value: 'America/Argentina/Rio_Gallegos', label: 'America/Argentina/Rio_Gallegos' },
{ value: 'America/Argentina/Salta', label: 'America/Argentina/Salta' },
{ value: 'America/Argentina/San_Juan', label: 'America/Argentina/San_Juan' },
{ value: 'America/Argentina/San_Luis', label: 'America/Argentina/San_Luis' },
{ value: 'America/Argentina/Tucuman', label: 'America/Argentina/Tucuman' },
{ value: 'America/Argentina/Ushuaia', label: 'America/Argentina/Ushuaia' },
{ value: 'America/Aruba', label: 'America/Aruba' },
{ value: 'America/Asuncion', label: 'America/Asuncion' },
{ value: 'America/Atikokan', label: 'America/Atikokan' },
{ value: 'America/Bahia', label: 'America/Bahia' },
{ value: 'America/Bahia_Banderas', label: 'America/Bahia_Banderas' },
{ value: 'America/Barbados', label: 'America/Barbados' },
{ value: 'America/Belem', label: 'America/Belem' },
{ value: 'America/Belize', label: 'America/Belize' },
{ value: 'America/Blanc-Sablon', label: 'America/Blanc-Sablon' },
{ value: 'America/Boa_Vista', label: 'America/Boa_Vista' },
{ value: 'America/Bogota', label: 'America/Bogota' },
{ value: 'America/Boise', label: 'America/Boise' },
{ value: 'America/Cambridge_Bay', label: 'America/Cambridge_Bay' },
{ value: 'America/Campo_Grande', label: 'America/Campo_Grande' },
{ value: 'America/Cancun', label: 'America/Cancun' },
{ value: 'America/Caracas', label: 'America/Caracas' },
{ value: 'America/Cayenne', label: 'America/Cayenne' },
{ value: 'America/Cayman', label: 'America/Cayman' },
{ value: 'America/Chicago', label: 'America/Chicago' },
{ value: 'America/Chihuahua', label: 'America/Chihuahua' },
{ value: 'America/Ciudad_Juarez', label: 'America/Ciudad_Juarez' },
{ value: 'America/Costa_Rica', label: 'America/Costa_Rica' },
{ value: 'America/Creston', label: 'America/Creston' },
{ value: 'America/Cuiaba', label: 'America/Cuiaba' },
{ value: 'America/Curacao', label: 'America/Curacao' },
{ value: 'America/Danmarkshavn', label: 'America/Danmarkshavn' },
{ value: 'America/Dawson', label: 'America/Dawson' },
{ value: 'America/Dawson_Creek', label: 'America/Dawson_Creek' },
{ value: 'America/Denver', label: 'America/Denver' },
{ value: 'America/Detroit', label: 'America/Detroit' },
{ value: 'America/Dominica', label: 'America/Dominica' },
{ value: 'America/Edmonton', label: 'America/Edmonton' },
{ value: 'America/Eirunepe', label: 'America/Eirunepe' },
{ value: 'America/El_Salvador', label: 'America/El_Salvador' },
{ value: 'America/Fort_Nelson', label: 'America/Fort_Nelson' },
{ value: 'America/Fortaleza', label: 'America/Fortaleza' },
{ value: 'America/Glace_Bay', label: 'America/Glace_Bay' },
{ value: 'America/Goose_Bay', label: 'America/Goose_Bay' },
{ value: 'America/Grand_Turk', label: 'America/Grand_Turk' },
{ value: 'America/Grenada', label: 'America/Grenada' },
{ value: 'America/Guadeloupe', label: 'America/Guadeloupe' },
{ value: 'America/Guatemala', label: 'America/Guatemala' },
{ value: 'America/Guayaquil', label: 'America/Guayaquil' },
{ value: 'America/Guyana', label: 'America/Guyana' },
{ value: 'America/Halifax', label: 'America/Halifax' },
{ value: 'America/Havana', label: 'America/Havana' },
{ value: 'America/Hermosillo', label: 'America/Hermosillo' },
{ value: 'America/Indiana/Indianapolis', label: 'America/Indiana/Indianapolis' },
{ value: 'America/Indiana/Knox', label: 'America/Indiana/Knox' },
{ value: 'America/Indiana/Marengo', label: 'America/Indiana/Marengo' },
{ value: 'America/Indiana/Petersburg', label: 'America/Indiana/Petersburg' },
{ value: 'America/Indiana/Tell_City', label: 'America/Indiana/Tell_City' },
{ value: 'America/Indiana/Vevay', label: 'America/Indiana/Vevay' },
{ value: 'America/Indiana/Vincennes', label: 'America/Indiana/Vincennes' },
{ value: 'America/Indiana/Winamac', label: 'America/Indiana/Winamac' },
{ value: 'America/Inuvik', label: 'America/Inuvik' },
{ value: 'America/Iqaluit', label: 'America/Iqaluit' },
{ value: 'America/Jamaica', label: 'America/Jamaica' },
{ value: 'America/Juneau', label: 'America/Juneau' },
{ value: 'America/Kentucky/Louisville', label: 'America/Kentucky/Louisville' },
{ value: 'America/Kentucky/Monticello', label: 'America/Kentucky/Monticello' },
{ value: 'America/Kralendijk', label: 'America/Kralendijk' },
{ value: 'America/La_Paz', label: 'America/La_Paz' },
{ value: 'America/Lima', label: 'America/Lima' },
{ value: 'America/Los_Angeles', label: 'America/Los_Angeles' },
{ value: 'America/Lower_Princes', label: 'America/Lower_Princes' },
{ value: 'America/Maceio', label: 'America/Maceio' },
{ value: 'America/Managua', label: 'America/Managua' },
{ value: 'America/Manaus', label: 'America/Manaus' },
{ value: 'America/Marigot', label: 'America/Marigot' },
{ value: 'America/Martinique', label: 'America/Martinique' },
{ value: 'America/Matamoros', label: 'America/Matamoros' },
{ value: 'America/Mazatlan', label: 'America/Mazatlan' },
{ value: 'America/Menominee', label: 'America/Menominee' },
{ value: 'America/Merida', label: 'America/Merida' },
{ value: 'America/Metlakatla', label: 'America/Metlakatla' },
{ value: 'America/Mexico_City', label: 'America/Mexico_City' },
{ value: 'America/Miquelon', label: 'America/Miquelon' },
{ value: 'America/Moncton', label: 'America/Moncton' },
{ value: 'America/Monterrey', label: 'America/Monterrey' },
{ value: 'America/Montevideo', label: 'America/Montevideo' },
{ value: 'America/Montserrat', label: 'America/Montserrat' },
{ value: 'America/Nassau', label: 'America/Nassau' },
{ value: 'America/New_York', label: 'America/New_York' },
{ value: 'America/Nome', label: 'America/Nome' },
{ value: 'America/Noronha', label: 'America/Noronha' },
{ value: 'America/North_Dakota/Beulah', label: 'America/North_Dakota/Beulah' },
{ value: 'America/North_Dakota/Center', label: 'America/North_Dakota/Center' },
{ value: 'America/North_Dakota/New_Salem', label: 'America/North_Dakota/New_Salem' },
{ value: 'America/Nuuk', label: 'America/Nuuk' },
{ value: 'America/Ojinaga', label: 'America/Ojinaga' },
{ value: 'America/Panama', label: 'America/Panama' },
{ value: 'America/Paramaribo', label: 'America/Paramaribo' },
{ value: 'America/Phoenix', label: 'America/Phoenix' },
{ value: 'America/Port-au-Prince', label: 'America/Port-au-Prince' },
{ value: 'America/Port_of_Spain', label: 'America/Port_of_Spain' },
{ value: 'America/Porto_Velho', label: 'America/Porto_Velho' },
{ value: 'America/Puerto_Rico', label: 'America/Puerto_Rico' },
{ value: 'America/Punta_Arenas', label: 'America/Punta_Arenas' },
{ value: 'America/Rankin_Inlet', label: 'America/Rankin_Inlet' },
{ value: 'America/Recife', label: 'America/Recife' },
{ value: 'America/Regina', label: 'America/Regina' },
{ value: 'America/Resolute', label: 'America/Resolute' },
{ value: 'America/Rio_Branco', label: 'America/Rio_Branco' },
{ value: 'America/Santarem', label: 'America/Santarem' },
{ value: 'America/Santiago', label: 'America/Santiago' },
{ value: 'America/Santo_Domingo', label: 'America/Santo_Domingo' },
{ value: 'America/Sao_Paulo', label: 'America/Sao_Paulo' },
{ value: 'America/Scoresbysund', label: 'America/Scoresbysund' },
{ value: 'America/Sitka', label: 'America/Sitka' },
{ value: 'America/St_Barthelemy', label: 'America/St_Barthelemy' },
{ value: 'America/St_Johns', label: 'America/St_Johns' },
{ value: 'America/St_Kitts', label: 'America/St_Kitts' },
{ value: 'America/St_Lucia', label: 'America/St_Lucia' },
{ value: 'America/St_Thomas', label: 'America/St_Thomas' },
{ value: 'America/St_Vincent', label: 'America/St_Vincent' },
{ value: 'America/Swift_Current', label: 'America/Swift_Current' },
{ value: 'America/Tegucigalpa', label: 'America/Tegucigalpa' },
{ value: 'America/Thule', label: 'America/Thule' },
{ value: 'America/Tijuana', label: 'America/Tijuana' },
{ value: 'America/Toronto', label: 'America/Toronto' },
{ value: 'America/Tortola', label: 'America/Tortola' },
{ value: 'America/Vancouver', label: 'America/Vancouver' },
{ value: 'America/Whitehorse', label: 'America/Whitehorse' },
{ value: 'America/Winnipeg', label: 'America/Winnipeg' },
{ value: 'America/Yakutat', label: 'America/Yakutat' },
{ value: 'Antarctica/Casey', label: 'Antarctica/Casey' },
{ value: 'Antarctica/Davis', label: 'Antarctica/Davis' },
{ value: 'Antarctica/DumontDUrville', label: 'Antarctica/DumontDUrville' },
{ value: 'Antarctica/Macquarie', label: 'Antarctica/Macquarie' },
{ value: 'Antarctica/Mawson', label: 'Antarctica/Mawson' },
{ value: 'Antarctica/McMurdo', label: 'Antarctica/McMurdo' },
{ value: 'Antarctica/Palmer', label: 'Antarctica/Palmer' },
{ value: 'Antarctica/Rothera', label: 'Antarctica/Rothera' },
{ value: 'Antarctica/Syowa', label: 'Antarctica/Syowa' },
{ value: 'Antarctica/Troll', label: 'Antarctica/Troll' },
{ value: 'Antarctica/Vostok', label: 'Antarctica/Vostok' },
{ value: 'Arctic/Longyearbyen', label: 'Arctic/Longyearbyen' },
{ value: 'Asia/Aden', label: 'Asia/Aden' },
{ value: 'Asia/Almaty', label: 'Asia/Almaty' },
{ value: 'Asia/Amman', label: 'Asia/Amman' },
{ value: 'Asia/Anadyr', label: 'Asia/Anadyr' },
{ value: 'Asia/Aqtau', label: 'Asia/Aqtau' },
{ value: 'Asia/Aqtobe', label: 'Asia/Aqtobe' },
{ value: 'Asia/Ashgabat', label: 'Asia/Ashgabat' },
{ value: 'Asia/Atyrau', label: 'Asia/Atyrau' },
{ value: 'Asia/Baghdad', label: 'Asia/Baghdad' },
{ value: 'Asia/Bahrain', label: 'Asia/Bahrain' },
{ value: 'Asia/Baku', label: 'Asia/Baku' },
{ value: 'Asia/Bangkok', label: 'Asia/Bangkok' },
{ value: 'Asia/Barnaul', label: 'Asia/Barnaul' },
{ value: 'Asia/Beirut', label: 'Asia/Beirut' },
{ value: 'Asia/Bishkek', label: 'Asia/Bishkek' },
{ value: 'Asia/Brunei', label: 'Asia/Brunei' },
{ value: 'Asia/Chita', label: 'Asia/Chita' },
{ value: 'Asia/Colombo', label: 'Asia/Colombo' },
{ value: 'Asia/Damascus', label: 'Asia/Damascus' },
{ value: 'Asia/Dhaka', label: 'Asia/Dhaka' },
{ value: 'Asia/Dili', label: 'Asia/Dili' },
{ value: 'Asia/Dubai', label: 'Asia/Dubai' },
{ value: 'Asia/Dushanbe', label: 'Asia/Dushanbe' },
{ value: 'Asia/Famagusta', label: 'Asia/Famagusta' },
{ value: 'Asia/Gaza', label: 'Asia/Gaza' },
{ value: 'Asia/Hebron', label: 'Asia/Hebron' },
{ value: 'Asia/Ho_Chi_Minh', label: 'Asia/Ho_Chi_Minh' },
{ value: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong' },
{ value: 'Asia/Hovd', label: 'Asia/Hovd' },
{ value: 'Asia/Irkutsk', label: 'Asia/Irkutsk' },
{ value: 'Asia/Jakarta', label: 'Asia/Jakarta' },
{ value: 'Asia/Jayapura', label: 'Asia/Jayapura' },
{ value: 'Asia/Jerusalem', label: 'Asia/Jerusalem' },
{ value: 'Asia/Kabul', label: 'Asia/Kabul' },
{ value: 'Asia/Kamchatka', label: 'Asia/Kamchatka' },
{ value: 'Asia/Karachi', label: 'Asia/Karachi' },
{ value: 'Asia/Kathmandu', label: 'Asia/Kathmandu' },
{ value: 'Asia/Khandyga', label: 'Asia/Khandyga' },
{ value: 'Asia/Kolkata', label: 'Asia/Kolkata' },
{ value: 'Asia/Krasnoyarsk', label: 'Asia/Krasnoyarsk' },
{ value: 'Asia/Kuala_Lumpur', label: 'Asia/Kuala_Lumpur' },
{ value: 'Asia/Kuching', label: 'Asia/Kuching' },
{ value: 'Asia/Kuwait', label: 'Asia/Kuwait' },
{ value: 'Asia/Macau', label: 'Asia/Macau' },
{ value: 'Asia/Magadan', label: 'Asia/Magadan' },
{ value: 'Asia/Makassar', label: 'Asia/Makassar' },
{ value: 'Asia/Manila', label: 'Asia/Manila' },
{ value: 'Asia/Muscat', label: 'Asia/Muscat' },
{ value: 'Asia/Nicosia', label: 'Asia/Nicosia' },
{ value: 'Asia/Novokuznetsk', label: 'Asia/Novokuznetsk' },
{ value: 'Asia/Novosibirsk', label: 'Asia/Novosibirsk' },
{ value: 'Asia/Omsk', label: 'Asia/Omsk' },
{ value: 'Asia/Oral', label: 'Asia/Oral' },
{ value: 'Asia/Phnom_Penh', label: 'Asia/Phnom_Penh' },
{ value: 'Asia/Pontianak', label: 'Asia/Pontianak' },
{ value: 'Asia/Pyongyang', label: 'Asia/Pyongyang' },
{ value: 'Asia/Qatar', label: 'Asia/Qatar' },
{ value: 'Asia/Qostanay', label: 'Asia/Qostanay' },
{ value: 'Asia/Qyzylorda', label: 'Asia/Qyzylorda' },
{ value: 'Asia/Riyadh', label: 'Asia/Riyadh' },
{ value: 'Asia/Sakhalin', label: 'Asia/Sakhalin' },
{ value: 'Asia/Samarkand', label: 'Asia/Samarkand' },
{ value: 'Asia/Seoul', label: 'Asia/Seoul' },
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai' },
{ value: 'Asia/Singapore', label: 'Asia/Singapore' },
{ value: 'Asia/Srednekolymsk', label: 'Asia/Srednekolymsk' },
{ value: 'Asia/Taipei', label: 'Asia/Taipei' },
{ value: 'Asia/Tashkent', label: 'Asia/Tashkent' },
{ value: 'Asia/Tbilisi', label: 'Asia/Tbilisi' },
{ value: 'Asia/Tehran', label: 'Asia/Tehran' },
{ value: 'Asia/Thimphu', label: 'Asia/Thimphu' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo' },
{ value: 'Asia/Tomsk', label: 'Asia/Tomsk' },
{ value: 'Asia/Ulaanbaatar', label: 'Asia/Ulaanbaatar' },
{ value: 'Asia/Urumqi', label: 'Asia/Urumqi' },
{ value: 'Asia/Ust-Nera', label: 'Asia/Ust-Nera' },
{ value: 'Asia/Vientiane', label: 'Asia/Vientiane' },
{ value: 'Asia/Vladivostok', label: 'Asia/Vladivostok' },
{ value: 'Asia/Yakutsk', label: 'Asia/Yakutsk' },
{ value: 'Asia/Yangon', label: 'Asia/Yangon' },
{ value: 'Asia/Yekaterinburg', label: 'Asia/Yekaterinburg' },
{ value: 'Asia/Yerevan', label: 'Asia/Yerevan' },
{ value: 'Atlantic/Azores', label: 'Atlantic/Azores' },
{ value: 'Atlantic/Bermuda', label: 'Atlantic/Bermuda' },
{ value: 'Atlantic/Canary', label: 'Atlantic/Canary' },
{ value: 'Atlantic/Cape_Verde', label: 'Atlantic/Cape_Verde' },
{ value: 'Atlantic/Faroe', label: 'Atlantic/Faroe' },
{ value: 'Atlantic/Madeira', label: 'Atlantic/Madeira' },
{ value: 'Atlantic/Reykjavik', label: 'Atlantic/Reykjavik' },
{ value: 'Atlantic/South_Georgia', label: 'Atlantic/South_Georgia' },
{ value: 'Atlantic/St_Helena', label: 'Atlantic/St_Helena' },
{ value: 'Atlantic/Stanley', label: 'Atlantic/Stanley' },
{ value: 'Australia/Adelaide', label: 'Australia/Adelaide' },
{ value: 'Australia/Brisbane', label: 'Australia/Brisbane' },
{ value: 'Australia/Broken_Hill', label: 'Australia/Broken_Hill' },
{ value: 'Australia/Darwin', label: 'Australia/Darwin' },
{ value: 'Australia/Eucla', label: 'Australia/Eucla' },
{ value: 'Australia/Hobart', label: 'Australia/Hobart' },
{ value: 'Australia/Lindeman', label: 'Australia/Lindeman' },
{ value: 'Australia/Lord_Howe', label: 'Australia/Lord_Howe' },
{ value: 'Australia/Melbourne', label: 'Australia/Melbourne' },
{ value: 'Australia/Perth', label: 'Australia/Perth' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney' },
{ value: 'Europe/Amsterdam', label: 'Europe/Amsterdam' },
{ value: 'Europe/Andorra', label: 'Europe/Andorra' },
{ value: 'Europe/Astrakhan', label: 'Europe/Astrakhan' },
{ value: 'Europe/Athens', label: 'Europe/Athens' },
{ value: 'Europe/Belgrade', label: 'Europe/Belgrade' },
{ value: 'Europe/Berlin', label: 'Europe/Berlin' },
{ value: 'Europe/Bratislava', label: 'Europe/Bratislava' },
{ value: 'Europe/Brussels', label: 'Europe/Brussels' },
{ value: 'Europe/Bucharest', label: 'Europe/Bucharest' },
{ value: 'Europe/Budapest', label: 'Europe/Budapest' },
{ value: 'Europe/Busingen', label: 'Europe/Busingen' },
{ value: 'Europe/Chisinau', label: 'Europe/Chisinau' },
{ value: 'Europe/Copenhagen', label: 'Europe/Copenhagen' },
{ value: 'Europe/Dublin', label: 'Europe/Dublin' },
{ value: 'Europe/Gibraltar', label: 'Europe/Gibraltar' },
{ value: 'Europe/Guernsey', label: 'Europe/Guernsey' },
{ value: 'Europe/Helsinki', label: 'Europe/Helsinki' },
{ value: 'Europe/Isle_of_Man', label: 'Europe/Isle_of_Man' },
{ value: 'Europe/Istanbul', label: 'Europe/Istanbul' },
{ value: 'Europe/Jersey', label: 'Europe/Jersey' },
{ value: 'Europe/Kaliningrad', label: 'Europe/Kaliningrad' },
{ value: 'Europe/Kirov', label: 'Europe/Kirov' },
{ value: 'Europe/Kyiv', label: 'Europe/Kyiv' },
{ value: 'Europe/Lisbon', label: 'Europe/Lisbon' },
{ value: 'Europe/Ljubljana', label: 'Europe/Ljubljana' },
{ value: 'Europe/London', label: 'Europe/London' },
{ value: 'Europe/Luxembourg', label: 'Europe/Luxembourg' },
{ value: 'Europe/Madrid', label: 'Europe/Madrid' },
{ value: 'Europe/Malta', label: 'Europe/Malta' },
{ value: 'Europe/Mariehamn', label: 'Europe/Mariehamn' },
{ value: 'Europe/Minsk', label: 'Europe/Minsk' },
{ value: 'Europe/Monaco', label: 'Europe/Monaco' },
{ value: 'Europe/Moscow', label: 'Europe/Moscow' },
{ value: 'Europe/Oslo', label: 'Europe/Oslo' },
{ value: 'Europe/Paris', label: 'Europe/Paris' },
{ value: 'Europe/Podgorica', label: 'Europe/Podgorica' },
{ value: 'Europe/Prague', label: 'Europe/Prague' },
{ value: 'Europe/Riga', label: 'Europe/Riga' },
{ value: 'Europe/Rome', label: 'Europe/Rome' },
{ value: 'Europe/Samara', label: 'Europe/Samara' },
{ value: 'Europe/San_Marino', label: 'Europe/San_Marino' },
{ value: 'Europe/Sarajevo', label: 'Europe/Sarajevo' },
{ value: 'Europe/Saratov', label: 'Europe/Saratov' },
{ value: 'Europe/Simferopol', label: 'Europe/Simferopol' },
{ value: 'Europe/Skopje', label: 'Europe/Skopje' },
{ value: 'Europe/Sofia', label: 'Europe/Sofia' },
{ value: 'Europe/Stockholm', label: 'Europe/Stockholm' },
{ value: 'Europe/Tallinn', label: 'Europe/Tallinn' },
{ value: 'Europe/Tirane', label: 'Europe/Tirane' },
{ value: 'Europe/Ulyanovsk', label: 'Europe/Ulyanovsk' },
{ value: 'Europe/Vaduz', label: 'Europe/Vaduz' },
{ value: 'Europe/Vatican', label: 'Europe/Vatican' },
{ value: 'Europe/Vienna', label: 'Europe/Vienna' },
{ value: 'Europe/Vilnius', label: 'Europe/Vilnius' },
{ value: 'Europe/Volgograd', label: 'Europe/Volgograd' },
{ value: 'Europe/Warsaw', label: 'Europe/Warsaw' },
{ value: 'Europe/Zagreb', label: 'Europe/Zagreb' },
{ value: 'Europe/Zurich', label: 'Europe/Zurich' },
{ value: 'Indian/Antananarivo', label: 'Indian/Antananarivo' },
{ value: 'Indian/Chagos', label: 'Indian/Chagos' },
{ value: 'Indian/Christmas', label: 'Indian/Christmas' },
{ value: 'Indian/Cocos', label: 'Indian/Cocos' },
{ value: 'Indian/Comoro', label: 'Indian/Comoro' },
{ value: 'Indian/Kerguelen', label: 'Indian/Kerguelen' },
{ value: 'Indian/Mahe', label: 'Indian/Mahe' },
{ value: 'Indian/Maldives', label: 'Indian/Maldives' },
{ value: 'Indian/Mauritius', label: 'Indian/Mauritius' },
{ value: 'Indian/Mayotte', label: 'Indian/Mayotte' },
{ value: 'Indian/Reunion', label: 'Indian/Reunion' },
{ value: 'Pacific/Apia', label: 'Pacific/Apia' },
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland' },
{ value: 'Pacific/Bougainville', label: 'Pacific/Bougainville' },
{ value: 'Pacific/Chatham', label: 'Pacific/Chatham' },
{ value: 'Pacific/Chuuk', label: 'Pacific/Chuuk' },
{ value: 'Pacific/Easter', label: 'Pacific/Easter' },
{ value: 'Pacific/Efate', label: 'Pacific/Efate' },
{ value: 'Pacific/Fakaofo', label: 'Pacific/Fakaofo' },
{ value: 'Pacific/Fiji', label: 'Pacific/Fiji' },
{ value: 'Pacific/Funafuti', label: 'Pacific/Funafuti' },
{ value: 'Pacific/Galapagos', label: 'Pacific/Galapagos' },
{ value: 'Pacific/Gambier', label: 'Pacific/Gambier' },
{ value: 'Pacific/Guadalcanal', label: 'Pacific/Guadalcanal' },
{ value: 'Pacific/Guam', label: 'Pacific/Guam' },
{ value: 'Pacific/Honolulu', label: 'Pacific/Honolulu' },
{ value: 'Pacific/Kanton', label: 'Pacific/Kanton' },
{ value: 'Pacific/Kiritimati', label: 'Pacific/Kiritimati' },
{ value: 'Pacific/Kosrae', label: 'Pacific/Kosrae' },
{ value: 'Pacific/Kwajalein', label: 'Pacific/Kwajalein' },
{ value: 'Pacific/Majuro', label: 'Pacific/Majuro' },
{ value: 'Pacific/Marquesas', label: 'Pacific/Marquesas' },
{ value: 'Pacific/Midway', label: 'Pacific/Midway' },
{ value: 'Pacific/Nauru', label: 'Pacific/Nauru' },
{ value: 'Pacific/Niue', label: 'Pacific/Niue' },
{ value: 'Pacific/Norfolk', label: 'Pacific/Norfolk' },
{ value: 'Pacific/Noumea', label: 'Pacific/Noumea' },
{ value: 'Pacific/Pago_Pago', label: 'Pacific/Pago_Pago' },
{ value: 'Pacific/Palau', label: 'Pacific/Palau' },
{ value: 'Pacific/Pitcairn', label: 'Pacific/Pitcairn' },
{ value: 'Pacific/Pohnpei', label: 'Pacific/Pohnpei' },
{ value: 'Pacific/Port_Moresby', label: 'Pacific/Port_Moresby' },
{ value: 'Pacific/Rarotonga', label: 'Pacific/Rarotonga' },
{ value: 'Pacific/Saipan', label: 'Pacific/Saipan' },
{ value: 'Pacific/Tahiti', label: 'Pacific/Tahiti' },
{ value: 'Pacific/Tarawa', label: 'Pacific/Tarawa' },
{ value: 'Pacific/Tongatapu', label: 'Pacific/Tongatapu' },
{ value: 'Pacific/Wake', label: 'Pacific/Wake' },
{ value: 'Pacific/Wallis', label: 'Pacific/Wallis' },
{ value: 'UTC', label: 'UTC' },
];

View File

@ -1,105 +0,0 @@
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import InputError from '@/components/ui/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function DeleteUser() {
const passwordInput = useRef<HTMLInputElement>(null);
const {
data,
setData,
delete: destroy,
processing,
reset,
errors,
clearErrors,
} = useForm<
Required<{
password: string;
}>
>({ password: '' });
const deleteUser: FormEventHandler = (e) => {
e.preventDefault();
destroy(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.current?.focus(),
onFinish: () => reset(),
});
};
const closeModal = () => {
clearErrors();
reset();
};
return (
<Card>
<CardHeader>
<CardTitle>Delete account</CardTitle>
<CardDescription>Delete your account and all of its resources</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
<p className="font-medium">Warning</p>
<p className="text-sm">Please proceed with caution, this cannot be undone.</p>
</div>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you
would like to permanently delete your account.
</DialogDescription>
<form className="space-y-6" onSubmit={deleteUser}>
<div className="grid gap-2">
<Label htmlFor="password" className="sr-only">
Password
</Label>
<Input
id="password"
type="password"
name="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
placeholder="Password"
autoComplete="current-password"
/>
<InputError message={errors.password} />
</div>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary" onClick={closeModal}>
Cancel
</Button>
</DialogClose>
<Button variant="destructive" disabled={processing} asChild>
<button type="submit">Delete account</button>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@ -6,7 +6,9 @@ import { FormEventHandler, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { CheckIcon, LoaderCircleIcon } from 'lucide-react';
export default function UpdatePassword() {
const passwordInput = useRef<HTMLInputElement>(null);
@ -44,73 +46,62 @@ export default function UpdatePassword() {
<CardTitle>Update password</CardTitle>
<CardDescription>Ensure your account is using a long, random password to stay secure.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={updatePassword} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<Input
id="current_password"
ref={currentPasswordInput}
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
/>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="flex items-center gap-4">
<Button disabled={processing}>Save password</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
</Transition>
</div>
</form>
<CardContent className="p-4">
<Form id="update-password-form" onSubmit={updatePassword}>
<FormFields>
<FormField>
<Label htmlFor="current_password">Current password</Label>
<Input
id="current_password"
ref={currentPasswordInput}
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
/>
<InputError message={errors.current_password} />
</FormField>
<FormField>
<Label htmlFor="password">New password</Label>
<Input
id="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
/>
<InputError message={errors.password} />
</FormField>
<FormField>
<Label htmlFor="password_confirmation">Confirm password</Label>
<Input
id="password_confirmation"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
type="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
/>
<InputError message={errors.password_confirmation} />
</FormField>
</FormFields>
</Form>
</CardContent>
<CardFooter className="gap-2">
<Button form="update-password-form" disabled={processing}>
{processing && <LoaderCircleIcon className="animate-spin" />}
Save password
</Button>
<Transition show={recentlySuccessful} enter="transition ease-in-out" enterFrom="opacity-0" leave="transition ease-in-out" leaveTo="opacity-0">
<CheckIcon className="text-success" />
</Transition>
</CardFooter>
</Card>
);
}

View File

@ -1,19 +1,21 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import InputError from '@/components/ui/input-error';
import { Link, useForm, usePage } from '@inertiajs/react';
import { useForm, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { Transition } from '@headlessui/react';
import type { SharedData } from '@/types';
import { FormEventHandler } from 'react';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { CheckIcon, LoaderCircleIcon } from 'lucide-react';
type ProfileForm = {
name: string;
email: string;
};
export default function UpdateUser({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
export default function UpdateUser() {
const { auth } = usePage<SharedData>().props;
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
@ -35,76 +37,46 @@ export default function UpdateUser({ mustVerifyEmail, status }: { mustVerifyEmai
<CardTitle>Profile information</CardTitle>
<CardDescription>Update your profile information and email address.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
autoComplete="name"
placeholder="Full name"
/>
<InputError className="mt-2" message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
className="mt-1 block w-full"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
autoComplete="username"
placeholder="Email address"
/>
<InputError className="mt-2" message={errors.email} />
</div>
{mustVerifyEmail && auth.user.email_verified_at === null && (
<div>
<p className="text-muted-foreground -mt-4 text-sm">
Your email address is unverified.{' '}
<Link
href={route('verification.send')}
method="post"
as="button"
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
Click here to resend the verification email.
</Link>
</p>
{status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600">A new verification link has been sent to your email address.</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button disabled={processing}>Save</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-neutral-600">Saved</p>
</Transition>
</div>
</form>
<CardContent className="p-4">
<Form id="update-profile-form" onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
autoComplete="name"
placeholder="Full name"
/>
<InputError message={errors.name} />
</FormField>
<FormField>
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
autoComplete="username"
placeholder="Email address"
/>
<InputError message={errors.email} />
</FormField>
</FormFields>
</Form>
</CardContent>
<CardFooter className="gap-2">
<Button form="update-profile-form" disabled={processing}>
{processing && <LoaderCircleIcon className="animate-spin" />}
Save
</Button>
<Transition show={recentlySuccessful} enter="transition ease-in-out" enterFrom="opacity-0" leave="transition ease-in-out" leaveTo="opacity-0">
<CheckIcon className="text-success" />
</Transition>
</CardFooter>
</Card>
);
}

View File

@ -1,20 +1,18 @@
import { Head } from '@inertiajs/react';
import DeleteUser from '@/pages/profile/components/delete-user';
import SettingsLayout from '@/layouts/settings/layout';
import Container from '@/components/container';
import UpdatePassword from '@/pages/profile/components/update-password';
import UpdateUser from '@/pages/profile/components/update-user';
import Heading from '@/components/heading';
export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) {
export default function Profile() {
return (
<SettingsLayout>
<Head title="Profile settings" />
<Container className="max-w-xl">
<Container className="max-w-5xl">
<Heading title="Profile settings" description="Manage your profile settings." />
<UpdateUser mustVerifyEmail={mustVerifyEmail} status={status} />
<UpdateUser />
<UpdatePassword />
<DeleteUser />
</Container>
</SettingsLayout>
);

View File

@ -4,6 +4,7 @@ import { MoreVerticalIcon } from 'lucide-react';
import { Project } from '@/types/project';
import DeleteProject from '@/pages/projects/components/delete-project';
import UsersAction from '@/pages/projects/components/users-action';
import ProjectForm from '@/pages/projects/components/project-form';
export default function ProjectActions({ project }: { project: Project }) {
return (
@ -15,6 +16,9 @@ export default function ProjectActions({ project }: { project: Project }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<ProjectForm project={project}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
</ProjectForm>
<UsersAction project={project}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Users</DropdownMenuItem>
</UsersAction>

View File

@ -1,11 +1,10 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import type { Project } from '@/types/project';
import ProjectActions from '@/pages/projects/components/actions';
import { usePage } from '@inertiajs/react';
import { SharedData } from '@/types';
import { Badge } from '@/components/ui/badge';
import DateTime from '@/components/date-time';
const CurrentProject = ({ project }: { project: Project }) => {
const page = usePage<SharedData>();
@ -34,10 +33,13 @@ export const columns: ColumnDef<Project>[] = [
},
},
{
accessorKey: 'created_at_by_timezone',
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
id: 'actions',

View File

@ -34,10 +34,11 @@ export default function DeleteProject({ project, children }: { project: Project;
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {project.name}</DialogTitle>
<DialogDescription>Delete project and all its resources.</DialogDescription>
<DialogDescription className="sr-only">Delete project and all its resources.</DialogDescription>
</DialogHeader>
<Form id="delete-project-form" onSubmit={submit}>
<Form id="delete-project-form" onSubmit={submit} className="p-4">
<p>Are you sure you want to delete this project? This action cannot be undone.</p>
<FormFields>
<FormField>
<Label htmlFor="project-name">Name</Label>
@ -49,7 +50,7 @@ export default function DeleteProject({ project, children }: { project: Project;
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="delete-project-form" variant="destructive" disabled={form.processing}>

View File

@ -10,23 +10,30 @@ import {
} from '@/components/ui/dialog';
import { FormEventHandler, ReactNode, useState } from 'react';
import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { CheckIcon, LoaderCircle } from 'lucide-react';
import { useForm } from '@inertiajs/react';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import InputError from '@/components/ui/input-error';
import { Project } from '@/types/project';
import { Transition } from '@headlessui/react';
export default function CreateProject({ children }: { children: ReactNode }) {
export default function ProjectForm({ project, children }: { project?: Project; children: ReactNode }) {
const [open, setOpen] = useState(false);
const form = useForm({
name: '',
name: project?.name || '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
if (project) {
form.patch(route('projects.update', project.id));
return;
}
form.post(route('projects.store'), {
onSuccess() {
setOpen(false);
@ -39,10 +46,10 @@ export default function CreateProject({ children }: { children: ReactNode }) {
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
<DialogDescription>Fill the form to create a new project</DialogDescription>
<DialogTitle>{project ? 'Edit Project' : 'Create Project'}</DialogTitle>
<DialogDescription className="sr-only">{project ? 'Edit the project details.' : 'Create a new project.'}</DialogDescription>
</DialogHeader>
<Form id="create-project-form" onSubmit={submit}>
<Form id="project-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="name">Name</Label>
@ -51,15 +58,24 @@ export default function CreateProject({ children }: { children: ReactNode }) {
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogFooter className="items-center">
<Transition
show={form.recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<CheckIcon className="text-success" />
</Transition>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button form="create-project-form" type="button" onClick={submit} disabled={form.processing}>
<Button form="project-form" type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Create
Save
</Button>
</DialogFooter>
</DialogContent>

View File

@ -20,17 +20,7 @@ import InputError from '@/components/ui/input-error';
import UserSelect from '@/components/user-select';
import { useForm } from '@inertiajs/react';
import { LoaderCircleIcon, TrashIcon } from 'lucide-react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogCancel,
AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
function AddUser({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
@ -41,7 +31,7 @@ function AddUser({ project }: { project: Project }) {
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(route('projects.users', project.id), {
form.post(route('projects.users.store', { project: project.id }), {
onSuccess: () => {
setOpen(false);
},
@ -51,14 +41,14 @@ function AddUser({ project }: { project: Project }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Add user</Button>
<Button>Add user</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add user</DialogTitle>
<DialogDescription>Here you can add new user to {project.name}</DialogDescription>
<DialogDescription className="sr-only">Here you can add new user to {project.name}</DialogDescription>
</DialogHeader>
<Form id="add-user-form" onSubmit={submit}>
<Form id="add-user-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="user">User</Label>
@ -71,7 +61,10 @@ function AddUser({ project }: { project: Project }) {
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="add-user-form">Add</Button>
<Button form="add-user-form" disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -81,13 +74,11 @@ function AddUser({ project }: { project: Project }) {
function RemoveUser({ project, user }: { project: Project; user: User }) {
const [open, setOpen] = useState(false);
const form = useForm({
user: user.id,
});
const form = useForm();
const submit = (e: FormEvent) => {
e.preventDefault();
form.delete(route('projects.users', project.id), {
form.delete(route('projects.users.destroy', { project: project.id, user: user.id }), {
onSuccess: () => {
setOpen(false);
},
@ -95,27 +86,33 @@ function RemoveUser({ project, user }: { project: Project; user: User }) {
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<TrashIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove user</AlertDialogTitle>
<AlertDialogDescription>Remove user from {project.name}.</AlertDialogDescription>
</AlertDialogHeader>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove user</DialogTitle>
<DialogDescription className="sr-only">Remove user from {project.name}.</DialogDescription>
</DialogHeader>
<AlertDialogFooter className="gap-2">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<p className="p-4">
Are you sure you want to remove <b>{user.name}</b> from this project?
</p>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={submit} variant="destructive" disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Remove user
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@ -155,18 +152,20 @@ const getColumns = (project: Project): ColumnDef<User>[] => [
export default function UsersAction({ project, children }: { project: Project; children: ReactNode }) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Users</DialogTitle>
<DialogDescription>Users assigned to this project</DialogDescription>
</DialogHeader>
<DataTable columns={getColumns(project)} data={project.users} />
<DialogFooter className="gap-2">
<AddUser project={project} />
</DialogFooter>
</DialogContent>
</Dialog>
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-3xl">
<SheetHeader>
<SheetTitle>Project users</SheetTitle>
<SheetDescription className="sr-only">Users assigned to this project</SheetDescription>
</SheetHeader>
<DataTable columns={getColumns(project)} data={project.users} modal />
<SheetFooter className="gap-2">
<div className="flex items-center">
<AddUser project={project} />
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -6,7 +6,7 @@ import { Project } from '@/types/project';
import Container from '@/components/container';
import Heading from '@/components/heading';
import React from 'react';
import CreateProject from '@/pages/projects/components/create-project';
import ProjectForm from '@/pages/projects/components/project-form';
import { Button } from '@/components/ui/button';
export default function Projects() {
@ -20,13 +20,13 @@ export default function Projects() {
<SettingsLayout>
<Head title="Projects" />
<Container className="max-w-3xl">
<Container className="max-w-5xl">
<div className="flex items-start justify-between">
<Heading title="Projects" description="Here you can manage your projects" />
<div className="flex items-center gap-2">
<CreateProject>
<Button variant="outline">Create project</Button>
</CreateProject>
<ProjectForm>
<Button>Create project</Button>
</ProjectForm>
</div>
</div>
<DataTable columns={columns} data={page.props.projects.data} />

View File

@ -1,13 +1,12 @@
'use client';
import { ColumnDef, Row } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { EyeIcon, LoaderCircleIcon } from 'lucide-react';
import type { ServerLog } from '@/types/server-log';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { useState } from 'react';
import axios from 'axios';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import DateTime from '@/components/date-time';
const LogActionCell = ({ row }: { row: Row<ServerLog> }) => {
const [open, setOpen] = useState(false);
@ -40,15 +39,18 @@ const LogActionCell = ({ row }: { row: Row<ServerLog> }) => {
{loading ? <LoaderCircleIcon className="animate-spin" /> : <EyeIcon />}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>View Log</DialogTitle>
<DialogDescription>This is all content of the log</DialogDescription>
<DialogDescription className="sr-only">This is all content of the log</DialogDescription>
</DialogHeader>
<ScrollArea className="border-border bg-accent text-accent-foreground relative h-[500px] w-full rounded-md border p-3 font-mono text-sm whitespace-pre-line">
<ScrollArea className="bg-accent text-accent-foreground relative h-[500px] w-full p-4 font-mono text-sm whitespace-pre-line">
{content}
<ScrollBar orientation="vertical" />
</ScrollArea>
<DialogFooter>
<Button variant="outline">Download</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@ -62,9 +64,12 @@ export const columns: ColumnDef<ServerLog>[] = [
enableColumnFilter: true,
},
{
accessorKey: 'created_at_by_timezone',
accessorKey: 'created_at',
header: 'Created At',
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
id: 'actions',

View File

@ -81,7 +81,7 @@ export default function CreateServerProvider({
<DialogTitle>Connect</DialogTitle>
<DialogDescription>Connect to a new server provider</DialogDescription>
</DialogHeader>
<Form id="create-server-provider-form" onSubmit={submit} className="py-4">
<Form id="create-server-provider-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="provider">Provider</Label>

View File

@ -1,11 +1,10 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { Server } from '@/types/server';
import { Link } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { EyeIcon } from 'lucide-react';
import ServerStatus from '@/pages/servers/components/status';
import DateTime from '@/components/date-time';
export const columns: ColumnDef<Server>[] = [
{
@ -27,6 +26,15 @@ export const columns: ColumnDef<Server>[] = [
enableColumnFilter: true,
enableSorting: true,
},
{
accessorKey: 'created_at',
header: 'Created at',
enableColumnFilter: true,
enableSorting: true,
cell: ({ row }) => {
return <DateTime date={row.original.created_at} />;
},
},
{
accessorKey: 'status',
header: 'Status',
@ -43,7 +51,7 @@ export const columns: ColumnDef<Server>[] = [
cell: ({ row }) => {
return (
<div className="flex items-center justify-end">
<Link href={route('servers.show', { server: row.original.id })}>
<Link href={route('servers.show', { server: row.original.id })} prefetch>
<Button variant="outline" size="sm">
<EyeIcon />
</Button>

View File

@ -37,7 +37,7 @@ export default function DeleteServer({ server, children }: { server: Server; chi
<DialogDescription>Delete server and its resources.</DialogDescription>
</DialogHeader>
<Form id="delete-server-form" onSubmit={submit}>
<Form id="delete-server-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="server-name">Name</Label>
@ -49,7 +49,7 @@ export default function DeleteServer({ server, children }: { server: Server; chi
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="delete-server-form" variant="destructive" disabled={form.processing}>

View File

@ -9,7 +9,7 @@ export default function ServerHeader({ server }: { server: Server }) {
return (
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="space-y-2">
<div className="text-accent-foreground/50 flex items-center space-x-2 text-xs">
<div className="flex items-center space-x-2 text-xs">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1">

View File

@ -31,7 +31,7 @@ export default function Servers() {
<Heading title="Servers" description="All of the servers of your project listed here" />
<div className="flex items-center gap-2">
<CreateServer>
<Button variant="outline">Create server</Button>
<Button>Create server</Button>
</CreateServer>
</div>
</div>

View File

@ -14,7 +14,7 @@ export default function InstallingServer() {
}>();
return (
<Container className="max-w-3xl">
<Container className="max-w-5xl">
<DataTable columns={columns} data={page.props.logs.data} />{' '}
</Container>
);

View File

@ -15,7 +15,7 @@ export default function ServerOverview() {
}>();
return (
<Container className="max-w-3xl">
<Container className="max-w-5xl">
<Heading title="Overview" description="Here you can see an overview of your server" />
<DataTable columns={columns} data={page.props.logs.data} />
</Container>

View File

@ -0,0 +1,34 @@
import { User } from '@/types/user';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { MoreVerticalIcon } from 'lucide-react';
import DeleteUser from '@/pages/users/components/delete-user';
import Projects from '@/pages/users/components/projects';
import UserForm from '@/pages/users/components/form';
export default function UserActions({ user }: { user: User }) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreVerticalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<UserForm user={user}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Edit</DropdownMenuItem>
</UserForm>
<Projects user={user}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>Projects</DropdownMenuItem>
</Projects>
<DropdownMenuSeparator />
<DeleteUser user={user}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} variant="destructive">
Delete
</DropdownMenuItem>
</DeleteUser>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,58 @@
import { User } from '@/types/user';
import { FormEvent, ReactNode, useState } from 'react';
import { useForm } from '@inertiajs/react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon } from 'lucide-react';
export default function DeleteUser({ user, children }: { user: User; children: ReactNode }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = (e: FormEvent) => {
e.preventDefault();
form.delete(route('users.destroy', user.id), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete user</DialogTitle>
<DialogDescription className="sr-only">
Delete {user.name}[{user.email}]
</DialogDescription>
</DialogHeader>
<p className="p-4">
Are you sure you want to delete{' '}
<b>
{user.name} [{user.email}]
</b>
? This action cannot be undone.
</p>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={submit} variant="destructive" disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Delete user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,125 @@
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { FormEventHandler, ReactNode, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CheckIcon, LoaderCircle } from 'lucide-react';
import { useForm } from '@inertiajs/react';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import InputError from '@/components/ui/input-error';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { User } from '@/types/user';
import { Transition } from '@headlessui/react';
export default function UserForm({ user, children }: { user?: User; children: ReactNode }) {
const [open, setOpen] = useState(false);
const form = useForm({
name: user?.name || '',
email: user?.email || '',
password: '',
role: user?.role || 'user',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
if (user) {
form.patch(route('users.update', user.id));
return;
}
form.post(route('users.store'), {
onSuccess() {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{user ? `Edit ${user.name}` : 'Create user'}</DialogTitle>
<DialogDescription className="sr-only">
{user ? `Fill the form to edit ${user.name}` : 'Fill the form to create a new user'}
</DialogDescription>
</DialogHeader>
<Form id="user-form" onSubmit={submit} className="p-4">
<FormFields>
<FormField>
<Label htmlFor="name">Name</Label>
<Input type="text" id="name" name="name" value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} />
<InputError message={form.errors.name} />
</FormField>
<FormField>
<Label htmlFor="email">Email</Label>
<Input type="email" id="email" name="email" value={form.data.email} onChange={(e) => form.setData('email', e.target.value)} />
<InputError message={form.errors.email} />
</FormField>
<FormField>
<Label htmlFor="password">Password</Label>
<Input
type="password"
id="password"
name="password"
value={form.data.password}
onChange={(e) => form.setData('password', e.target.value)}
/>
<InputError message={form.errors.password} />
</FormField>
<FormField>
<Label htmlFor="role">Role</Label>
<Select value={form.data.role} onValueChange={(value) => form.setData('role', value)}>
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem key="role-user" value="user">
user
</SelectItem>
<SelectItem key="role-admin" value="admin">
admin
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.role} />
</FormField>
</FormFields>
</Form>
<DialogFooter className="items-center">
<Transition
show={form.recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<CheckIcon className="text-success" />
</Transition>
<DialogClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</DialogClose>
<Button form="create-user-form" type="button" onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircle className="animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,47 @@
import { ColumnDef } from '@tanstack/react-table';
import type { User } from '@/types/user';
import { DataTable } from '@/components/data-table';
import { usePage } from '@inertiajs/react';
import UserActions from '@/pages/users/components/actions';
import DateTime from '@/components/date-time';
const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
},
{
accessorKey: 'email',
header: 'Email',
enableColumnFilter: true,
},
{
accessorKey: 'created_at',
header: 'Created At',
enableSorting: true,
cell: ({ row }) => <DateTime date={row.original.created_at} />,
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center justify-end">
<UserActions user={row.original} />
</div>
),
},
];
type Page = {
users: {
data: User[];
};
};
export default function UsersList() {
const page = usePage<Page>();
return <DataTable columns={columns} data={page.props.users.data} />;
}

View File

@ -0,0 +1,176 @@
import { User } from '@/types/user';
import { FormEvent, ReactNode, useState } from 'react';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DialogTrigger } from '@radix-ui/react-dialog';
import { ColumnDef } from '@tanstack/react-table';
import type { Project } from '@/types/project';
import { DataTable } from '@/components/data-table';
import { useForm, usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import { LoaderCircleIcon, TrashIcon } from 'lucide-react';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Form, FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import InputError from '@/components/ui/input-error';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SharedData } from '@/types';
function RemoveProject({ user, project }: { user: User; project: Project }) {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.delete(route('users.projects.destroy', { user: user.id, project: project.id }), {
preserveScroll: true,
onSuccess: () => {
form.reset();
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button key={`remove-user-${user.id}`} variant="outline" size="sm" tabIndex={-1}>
<TrashIcon />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove from project</DialogTitle>
<DialogDescription className="sr-only">
Remove <b>{user.name}</b> from <b>{project.name}</b>
</DialogDescription>
</DialogHeader>
<p className="p-4">
Are you sure you want to remove <b>{user.name}</b> from <b>{project.name}</b>?
</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button variant="destructive" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function AddToProject({ user }: { user: User }) {
const page = usePage<SharedData>();
const [open, setOpen] = useState(false);
const form = useForm({
project: '',
});
const submit = (e: FormEvent) => {
e.preventDefault();
form.post(route('users.projects.store', { user: user.id }), {
onSuccess: () => {
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button key={`add-project-${user.id}`}>Add to project</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add to project</DialogTitle>
<DialogDescription className="sr-only">
Add <b>{user.name}</b> to a project
</DialogDescription>
</DialogHeader>
<Form id="add-to-project-form" className="p-4" onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="project">Project</Label>
<Select value={form.data.project} onValueChange={(value) => form.setData('project', value)}>
<SelectTrigger id="project">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{page.props.auth.projects.map((project: Project) => (
<SelectItem key={`project-${project.id}`} value={project.id.toString()}>
{project.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<InputError message={form.errors.project} />
</FormField>
</FormFields>
</Form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button form="add-to-project-form" disabled={form.processing} onClick={submit}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const columns = (user: User): ColumnDef<Project>[] => {
return [
{
accessorKey: 'id',
header: 'ID',
enableColumnFilter: true,
enableSorting: true,
enableHiding: true,
},
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
enableSorting: true,
},
{
id: 'actions',
enableColumnFilter: false,
enableSorting: false,
cell: ({ row }) => {
return (
<div className="flex items-center justify-end" key={row.id}>
<RemoveProject user={user} project={row.original} />
</div>
);
},
},
];
};
export default function Projects({ user, children }: { user: User; children: ReactNode }) {
return (
<Sheet>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-3xl">
<SheetHeader>
<SheetTitle>Projects</SheetTitle>
<SheetDescription className="sr-only">
All projects that <b>{user.name}</b> has access
</SheetDescription>
</SheetHeader>
{user.projects && <DataTable columns={columns(user)} data={user.projects} modal />}
<SheetFooter>
<div className="flex items-center">
<AddToProject user={user} />
</div>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,25 @@
import SettingsLayout from '@/layouts/settings/layout';
import { Head } from '@inertiajs/react';
import Container from '@/components/container';
import Heading from '@/components/heading';
import UsersList from '@/pages/users/components/list';
import { Button } from '@/components/ui/button';
import UserForm from '@/pages/users/components/form';
export default function Users() {
return (
<SettingsLayout>
<Head title="Users" />
<Container className="max-w-5xl">
<div className="flex items-start justify-between">
<Heading title="Users" description="Here you can manage all users" />
<UserForm>
<Button>Create user</Button>
</UserForm>
</div>
<UsersList />
</Container>
</SettingsLayout>
);
}

View File

@ -6,7 +6,5 @@ export interface Project {
users: User[];
created_at: string;
updated_at: string;
created_at_by_timezone: string;
updated_at_by_timezone: string;
[key: string]: unknown;
}

View File

@ -8,8 +8,6 @@ export interface ServerLog {
is_remote: boolean;
created_at: string;
updated_at: string;
created_at_by_timezone: string;
updated_at_by_timezone: string;
[key: string]: unknown;
}

View File

@ -25,8 +25,6 @@ export interface Server {
last_update_check?: string;
created_at: string;
updated_at: string;
created_at_by_timezone: string;
updated_at_by_timezone: string;
status_color: 'gray' | 'success' | 'info' | 'warning' | 'danger';
[key: string]: unknown;
}

View File

@ -1,3 +1,5 @@
import { Project } from '@/types/project';
export interface User {
id: number;
name: string;
@ -6,5 +8,8 @@ export interface User {
email_verified_at: string | null;
created_at: string;
updated_at: string;
timezone: string;
projects?: Project[];
role: string;
[key: string]: unknown; // This allows for additional properties...
}

View File

@ -44,7 +44,7 @@
@vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"])
@inertiaHead
</head>
<body class="font-sans antialiased">
<body class="selection:bg-brand font-sans antialiased selection:text-white">
@inertia
</body>
</html>

View File

@ -2,12 +2,9 @@
namespace Tests\Feature;
use App\Web\Pages\Settings\Profile\Index;
use App\Web\Pages\Settings\Profile\Widgets\ProfileInformation;
use App\Web\Pages\Settings\Profile\Widgets\UpdatePassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProfileTest extends TestCase
@ -19,44 +16,38 @@ public function test_profile_page_is_displayed(): void
$this->actingAs($this->user);
$this
->get(Index::getUrl())
->get(route('profile'))
->assertSuccessful()
->assertSee('Profile Information')
->assertSee('Update Password')
->assertSee('Browser Sessions')
->assertSee('Two Factor Authentication');
->assertInertia(fn (Assert $page) => $page->component('profile/index'));
}
public function test_profile_information_can_be_updated(): void
{
$this->actingAs($this->user);
Livewire::test(ProfileInformation::class)
->fill([
'name' => 'Test',
'email' => 'test@example.com',
'timezone' => 'Europe/Berlin',
])
->call('submit');
$this->patch(route('profile.update'), [
'name' => 'Test',
'email' => 'test@example.com',
])
->assertRedirect(route('profile'));
$this->user->refresh();
$this->assertSame('Test', $this->user->name);
$this->assertSame('test@example.com', $this->user->email);
$this->assertSame('Europe/Berlin', $this->user->timezone);
}
public function test_password_can_be_updated(): void
{
$this->actingAs($this->user);
Livewire::test(UpdatePassword::class)
->fill([
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->call('submit');
$this->put(route('profile.password'), [
'current_password' => 'password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->assertRedirect(route('profile'))
->assertSessionDoesntHaveErrors();
$this->assertTrue(Hash::check('new-password', $this->user->refresh()->password));
}
@ -64,14 +55,11 @@ public function test_password_can_be_updated(): void
public function test_correct_password_must_be_provided_to_update_password(): void
{
$this->actingAs($this->user);
Livewire::test(UpdatePassword::class)
->fill([
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->call('submit')
->assertHasErrors('current_password');
$this->put(route('profile.password'), [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])
->assertSessionHasErrors('current_password');
}
}

View File

@ -3,11 +3,8 @@
namespace Tests\Feature;
use App\Models\Project;
use App\Web\Pages\Settings\Projects\Index;
use App\Web\Pages\Settings\Projects\Settings;
use App\Web\Pages\Settings\Projects\Widgets\UpdateProject;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ProjectsTest extends TestCase
@ -18,15 +15,17 @@ public function test_create_project(): void
{
$this->actingAs($this->user);
Livewire::test(Index::class)
->callAction('create', [
'name' => 'test',
])
->assertSuccessful();
$this->post(route('projects.store'), [
'name' => 'create-project-test',
])
->assertSessionDoesntHaveErrors()
->assertRedirect(route('projects'));
$this->assertDatabaseHas('projects', [
'name' => 'test',
'name' => 'create-project-test',
]);
$this->assertEquals($this->user->refresh()->current_project_id, Project::query()->where('name', 'create-project-test')->first()->id);
}
public function test_see_projects_list(): void
@ -37,9 +36,10 @@ public function test_see_projects_list(): void
$this->user->projects()->attach($project);
$this->get(Index::getUrl())
$this->get(route('projects'))
->assertSuccessful()
->assertSee($project->name);
->assertInertia(fn (AssertableInertia $page) => $page->component('projects/index'));
}
public function test_delete_project(): void
@ -50,11 +50,11 @@ public function test_delete_project(): void
$this->user->projects()->attach($project);
Livewire::test(Settings::class, [
'project' => $project,
$this->delete(route('projects.destroy', $project), [
'name' => $project->name,
])
->callAction('delete')
->assertSuccessful();
->assertSessionDoesntHaveErrors()
->assertRedirect(route('projects'));
$this->assertDatabaseMissing('projects', [
'id' => $project->id,
@ -69,14 +69,11 @@ public function test_edit_project(): void
$this->user->projects()->attach($project);
Livewire::test(UpdateProject::class, [
'project' => $project,
$this->patch(route('projects.update', $project), [
'name' => 'new-name',
])
->fill([
'name' => 'new-name',
])
->call('submit')
->assertSuccessful();
->assertSessionDoesntHaveErrors()
->assertRedirect(route('projects'));
$this->assertDatabaseHas('projects', [
'id' => $project->id,
@ -88,11 +85,12 @@ public function test_cannot_delete_last_project(): void
{
$this->actingAs($this->user);
Livewire::test(Settings::class, [
'project' => $this->user->currentProject,
$this->delete(route('projects.destroy', $this->user->currentProject->id), [
'name' => $this->user->currentProject->name,
])
->callAction('delete')
->assertNotified('Cannot delete the last project.');
->assertSessionHasErrors([
'name' => 'Cannot delete the last project.',
]);
$this->assertDatabaseHas('projects', [
'id' => $this->user->currentProject->id,

View File

@ -5,10 +5,8 @@
use App\Enums\UserRole;
use App\Models\Project;
use App\Models\User;
use App\Web\Pages\Settings\Users\Index;
use App\Web\Pages\Settings\Users\Widgets\UsersList;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class UserTest extends TestCase
@ -19,13 +17,14 @@ public function test_create_user(): void
{
$this->actingAs($this->user);
Livewire::test(Index::class)
->callAction('create', [
'name' => 'new user',
'email' => 'newuser@example.com',
'password' => 'password',
'role' => UserRole::USER,
]);
$this->post(route('users.store'), [
'name' => 'new user',
'email' => 'newuser@example.com',
'password' => 'password',
'role' => UserRole::USER,
])
->assertSessionDoesntHaveErrors()
->assertRedirect(route('users'));
$this->assertDatabaseHas('users', [
'name' => 'new user',
@ -38,13 +37,11 @@ public function test_see_users_list(): void
{
$this->actingAs($this->user);
$user = User::factory()->create();
User::factory()->create();
$this->get(Index::getUrl())
->assertSuccessful();
Livewire::test(UsersList::class)
->assertCanSeeTableRecords([$user]);
$this->get(route('users'))
->assertSuccessful()
->assertInertia(fn (Assert $page) => $page->component('users/index'));
}
public function test_must_be_admin_to_see_users_list(): void
@ -54,7 +51,7 @@ public function test_must_be_admin_to_see_users_list(): void
$this->actingAs($this->user);
$this->get(Index::getUrl())
$this->get(route('users'))
->assertForbidden();
}
@ -64,8 +61,9 @@ public function test_delete_user(): void
$user = User::factory()->create();
Livewire::test(UsersList::class)
->callTableAction('delete', $user);
$this->delete(route('users.destroy', $user))
->assertSessionDoesntHaveErrors()
->assertRedirect(route('users'));
$this->assertDatabaseMissing('users', [
'id' => $user->id,
@ -76,8 +74,8 @@ public function test_cannot_delete_yourself(): void
{
$this->actingAs($this->user);
Livewire::test(UsersList::class)
->assertTableActionHidden('delete', $this->user);
$this->delete(route('users.destroy', $this->user))
->assertForbidden();
}
public function test_edit_user_info(): void
@ -86,36 +84,33 @@ public function test_edit_user_info(): void
$user = User::factory()->create();
Livewire::test(UsersList::class)
->callTableAction('edit', $user, [
'name' => 'new-name',
'email' => 'newemail@example.com',
'timezone' => 'Europe/London',
'role' => UserRole::ADMIN,
])
->assertSuccessful();
$this->patch(route('users.update', $user), [
'name' => 'new-name',
'email' => 'newemail@example.com',
'role' => UserRole::ADMIN,
])
->assertRedirect(route('users'));
$this->assertDatabaseHas('users', [
'id' => $user->id,
'name' => 'new-name',
'email' => 'newemail@example.com',
'timezone' => 'Europe/London',
'role' => UserRole::ADMIN,
]);
}
public function test_edit_user_projects(): void
public function test_add_user_to_project(): void
{
$this->actingAs($this->user);
$user = User::factory()->create();
$project = Project::factory()->create();
Livewire::test(UsersList::class)
->callTableAction('update-projects', $user, [
'projects' => [$project->id],
])
->assertSuccessful();
$this->post(route('users.projects.store', $user), [
'project' => $project->id,
])
->assertSessionDoesntHaveErrors()
->assertRedirect(route('users'));
$this->assertDatabaseHas('user_project', [
'user_id' => $user->id,
@ -123,32 +118,22 @@ public function test_edit_user_projects(): void
]);
}
public function test_edit_user_projects_with_current_project(): void
public function test_remove_user_from_project(): void
{
$this->actingAs($this->user);
/** @var User $user */
$user = User::factory()->create();
$user->current_project_id = null;
$user->save();
/** @var Project $project */
$project = Project::factory()->create();
Livewire::test(UsersList::class)
->callTableAction('update-projects', $user, [
'projects' => [$project->id],
])
->assertSuccessful();
$user->projects()->attach($project);
$this->assertDatabaseHas('user_project', [
$this->delete(route('users.projects.destroy', [$user, $project]))
->assertSessionDoesntHaveErrors()
->assertRedirect(route('users'));
$this->assertDatabaseMissing('user_project', [
'user_id' => $user->id,
'project_id' => $project->id,
]);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'current_project_id' => $project->id,
]);
}
}