Merge pull request 'feat: tabbed profile page with PIN setup and auto employee numbers' (#11) from feature/user-profile into main
All checks were successful
Build & Release / build (push) Successful in 1m19s
All checks were successful
Build & Release / build (push) Successful in 1m19s
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
66
packages/admin/src/components/ui/alert.tsx
Normal file
66
packages/admin/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
@@ -11,10 +11,23 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Sun, Moon, Monitor } from 'lucide-react'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Sun, Moon, Monitor, AlertTriangle } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
role: string
|
||||||
|
employeeNumber: string | null
|
||||||
|
hasPin: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/profile')({
|
export const Route = createFileRoute('/_authenticated/profile')({
|
||||||
component: ProfilePage,
|
component: ProfilePage,
|
||||||
})
|
})
|
||||||
@@ -22,21 +35,73 @@ export const Route = createFileRoute('/_authenticated/profile')({
|
|||||||
function profileOptions() {
|
function profileOptions() {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryKey: ['auth', 'me'],
|
queryKey: ['auth', 'me'],
|
||||||
queryFn: () => api.get<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me'),
|
queryFn: () => api.get<Profile>('/v1/auth/me'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
|
const { tab } = Route.useSearch() as { tab?: string }
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const setAuth = useAuthStore((s) => s.setAuth)
|
const setAuth = useAuthStore((s) => s.setAuth)
|
||||||
const storeUser = useAuthStore((s) => s.user)
|
const storeUser = useAuthStore((s) => s.user)
|
||||||
const storeToken = useAuthStore((s) => s.token)
|
const storeToken = useAuthStore((s) => s.token)
|
||||||
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
|
|
||||||
|
|
||||||
const { data: profile } = useQuery(profileOptions())
|
const { data: profile } = useQuery(profileOptions())
|
||||||
|
|
||||||
const [firstName, setFirstName] = useState('')
|
return (
|
||||||
const [lastName, setLastName] = useState('')
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-bold">Profile</h1>
|
||||||
|
|
||||||
|
{profile && !profile.hasPin && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>POS PIN not set</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You need a PIN to use the Point of Sale.{' '}
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
search={{ tab: 'security' }}
|
||||||
|
className="underline font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Set your PIN
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue={tab === 'security' || tab === 'appearance' ? tab : 'account'}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">Security</TabsTrigger>
|
||||||
|
<TabsTrigger value="appearance">Appearance</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="account">
|
||||||
|
<AccountTab profile={profile} queryClient={queryClient} setAuth={setAuth} storeUser={storeUser} storeToken={storeToken} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="security">
|
||||||
|
<SecurityTab profile={profile} queryClient={queryClient} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appearance">
|
||||||
|
<AppearanceTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountTab({ profile, queryClient, setAuth, storeUser, storeToken }: {
|
||||||
|
profile: Profile | undefined
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>
|
||||||
|
setAuth: (token: string, user: any) => void
|
||||||
|
storeUser: any
|
||||||
|
storeToken: string | null
|
||||||
|
}) {
|
||||||
|
const [firstName, setFirstName] = useState(profile?.firstName ?? '')
|
||||||
|
const [lastName, setLastName] = useState(profile?.lastName ?? '')
|
||||||
const [nameLoaded, setNameLoaded] = useState(false)
|
const [nameLoaded, setNameLoaded] = useState(false)
|
||||||
|
|
||||||
if (profile && !nameLoaded) {
|
if (profile && !nameLoaded) {
|
||||||
@@ -45,12 +110,8 @@ function ProfilePage() {
|
|||||||
setNameLoaded(true)
|
setNameLoaded(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
|
||||||
const [newPassword, setNewPassword] = useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
|
||||||
|
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => api.patch<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me', data),
|
mutationFn: (data: Record<string, unknown>) => api.patch<Profile>('/v1/auth/me', data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
if (storeToken && storeUser) {
|
if (storeToken && storeUser) {
|
||||||
@@ -61,6 +122,59 @@ function ProfilePage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{profile?.id && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<AvatarUpload entityType="user" entityId={profile.id} size="lg" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{profile.firstName} {profile.lastName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{profile.email}</p>
|
||||||
|
{profile.employeeNumber && (
|
||||||
|
<p className="text-xs text-muted-foreground">Employee #{profile.employeeNumber}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>First Name</Label>
|
||||||
|
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Last Name</Label>
|
||||||
|
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => updateProfileMutation.mutate({ firstName, lastName })}
|
||||||
|
disabled={updateProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityTab({ profile, queryClient }: {
|
||||||
|
profile: Profile | undefined
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>
|
||||||
|
}) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [pin, setPin] = useState('')
|
||||||
|
const [confirmPin, setConfirmPin] = useState('')
|
||||||
|
|
||||||
const changePasswordMutation = useMutation({
|
const changePasswordMutation = useMutation({
|
||||||
mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }),
|
mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -72,6 +186,26 @@ function ProfilePage() {
|
|||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setPinMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/v1/auth/set-pin', { pin }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPin('')
|
||||||
|
setConfirmPin('')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
|
toast.success('PIN set')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removePinMutation = useMutation({
|
||||||
|
mutationFn: () => api.del('/v1/auth/pin'),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
|
||||||
|
toast.success('PIN removed')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
function handlePasswordChange() {
|
function handlePasswordChange() {
|
||||||
if (newPassword.length < 12) {
|
if (newPassword.length < 12) {
|
||||||
toast.error('Password must be at least 12 characters')
|
toast.error('Password must be at least 12 characters')
|
||||||
@@ -84,47 +218,24 @@ function ProfilePage() {
|
|||||||
changePasswordMutation.mutate()
|
changePasswordMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSetPin() {
|
||||||
|
if (pin.length < 4 || pin.length > 6) {
|
||||||
|
toast.error('PIN must be 4-6 digits')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(pin)) {
|
||||||
|
toast.error('PIN must be digits only')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pin !== confirmPin) {
|
||||||
|
toast.error('PINs do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPinMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Profile</h1>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Account</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{profile?.id && (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<AvatarUpload entityType="user" entityId={profile.id} size="lg" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{profile.firstName} {profile.lastName}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{profile.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Email</Label>
|
|
||||||
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>First Name</Label>
|
|
||||||
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Last Name</Label>
|
|
||||||
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => updateProfileMutation.mutate({ firstName, lastName })}
|
|
||||||
disabled={updateProfileMutation.isPending}
|
|
||||||
>
|
|
||||||
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Change Password</CardTitle>
|
<CardTitle className="text-lg">Change Password</CardTitle>
|
||||||
@@ -150,53 +261,113 @@ function ProfilePage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Appearance</CardTitle>
|
<CardTitle className="text-lg">POS PIN</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-2">
|
{profile?.employeeNumber && (
|
||||||
<Label>Mode</Label>
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="flex gap-2">
|
Your employee number is <span className="font-mono font-medium text-foreground">{profile.employeeNumber}</span>.
|
||||||
{[
|
To unlock the POS, enter your employee number followed by your PIN.
|
||||||
{ value: 'light' as const, icon: Sun, label: 'Light' },
|
</p>
|
||||||
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
|
)}
|
||||||
{ value: 'system' as const, icon: Monitor, label: 'System' },
|
{profile?.hasPin ? (
|
||||||
].map((m) => (
|
<>
|
||||||
<Button
|
<p className="text-sm text-muted-foreground">You have a PIN set. You can change or remove it below.</p>
|
||||||
key={m.value}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
variant={mode === m.value ? 'default' : 'secondary'}
|
<div className="space-y-2">
|
||||||
size="sm"
|
<Label>New PIN (4-6 digits)</Label>
|
||||||
onClick={() => setMode(m.value)}
|
<Input type="password" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value)} placeholder="****" />
|
||||||
>
|
</div>
|
||||||
<m.icon className="mr-2 h-4 w-4" />
|
<div className="space-y-2">
|
||||||
{m.label}
|
<Label>Confirm PIN</Label>
|
||||||
|
<Input type="password" inputMode="numeric" maxLength={6} value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} placeholder="****" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleSetPin} disabled={setPinMutation.isPending}>
|
||||||
|
{setPinMutation.isPending ? 'Saving...' : 'Change PIN'}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
<Button variant="outline" onClick={() => removePinMutation.mutate()} disabled={removePinMutation.isPending}>
|
||||||
</div>
|
{removePinMutation.isPending ? 'Removing...' : 'Remove PIN'}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Color Theme</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{themes.map((t) => (
|
|
||||||
<Button
|
|
||||||
key={t.name}
|
|
||||||
variant={colorTheme === t.name ? 'default' : 'secondary'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setColorTheme(t.name)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mr-2 h-3 w-3 rounded-full border inline-block"
|
|
||||||
style={{ backgroundColor: `hsl(${t.light.primary})` }}
|
|
||||||
/>
|
|
||||||
{t.label}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">Set a PIN to unlock the Point of Sale terminal.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>PIN (4-6 digits)</Label>
|
||||||
|
<Input type="password" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value)} placeholder="****" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Confirm PIN</Label>
|
||||||
|
<Input type="password" inputMode="numeric" maxLength={6} value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} placeholder="****" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSetPin} disabled={setPinMutation.isPending}>
|
||||||
|
{setPinMutation.isPending ? 'Saving...' : 'Set PIN'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppearanceTab() {
|
||||||
|
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">Appearance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mode</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'light' as const, icon: Sun, label: 'Light' },
|
||||||
|
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
|
||||||
|
{ value: 'system' as const, icon: Monitor, label: 'System' },
|
||||||
|
].map((m) => (
|
||||||
|
<Button
|
||||||
|
key={m.value}
|
||||||
|
variant={mode === m.value ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setMode(m.value)}
|
||||||
|
>
|
||||||
|
<m.icon className="mr-2 h-4 w-4" />
|
||||||
|
{m.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Color Theme</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{themes.map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t.name}
|
||||||
|
variant={colorTheme === t.name ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setColorTheme(t.name)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mr-2 h-3 w-3 rounded-full border inline-block"
|
||||||
|
style={{ backgroundColor: `hsl(${t.light.primary})` }}
|
||||||
|
/>
|
||||||
|
{t.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Auto-assign employee_number on user insert if not provided
|
||||||
|
CREATE OR REPLACE FUNCTION assign_employee_number()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE next_num INT;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.employee_number IS NULL OR NEW.employee_number = '' THEN
|
||||||
|
SELECT COALESCE(MAX(employee_number::int), 1000) + 1
|
||||||
|
INTO next_num
|
||||||
|
FROM "user"
|
||||||
|
WHERE employee_number ~ '^\d+$';
|
||||||
|
NEW.employee_number := next_num::text;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_assign_employee_number ON "user";
|
||||||
|
CREATE TRIGGER trg_assign_employee_number
|
||||||
|
BEFORE INSERT ON "user"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION assign_employee_number();
|
||||||
|
|
||||||
|
-- Backfill any users missing an employee number
|
||||||
|
DO $$ DECLARE r RECORD; num INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(employee_number::int), 1000) INTO num FROM "user" WHERE employee_number ~ '^\d+$';
|
||||||
|
FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL OR employee_number = '' ORDER BY created_at) LOOP
|
||||||
|
num := num + 1;
|
||||||
|
UPDATE "user" SET employee_number = num::text WHERE id = r.id;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -323,6 +323,13 @@
|
|||||||
"when": 1775770000000,
|
"when": 1775770000000,
|
||||||
"tag": "0045_registers-reports",
|
"tag": "0045_registers-reports",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 46,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775860000000,
|
||||||
|
"tag": "0046_auto-employee-number",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -274,6 +274,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
lastName: users.lastName,
|
lastName: users.lastName,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
|
employeeNumber: users.employeeNumber,
|
||||||
|
pinHash: users.pinHash,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -281,7 +283,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
|
||||||
return reply.send(user)
|
return reply.send({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
employeeNumber: user.employeeNumber,
|
||||||
|
hasPin: !!user.pinHash,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update current user profile
|
// Update current user profile
|
||||||
|
|||||||
Reference in New Issue
Block a user