This commit is contained in:
2026-04-06 02:32:47 +03:00
parent 5f3d208158
commit 24a44481a0
152 changed files with 85913 additions and 44338 deletions
@@ -0,0 +1,70 @@
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
import { useFormDialog } from "@/shared/components/form-dialog"
import { EmployeeForm } from "./employee-form"
type EmployeeActionsProps = {
employeeId: string
}
export function EmployeeActions({ employeeId }: EmployeeActionsProps) {
const api = useAuthApi()
const router = useRouter()
const editDialog = useFormDialog("employee_edit")
const handleDelete = async () => {
await api.employees.destroy(employeeId)
router.push("/productivity/employees")
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => editDialog.open(employeeId)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={editDialog.isOpen} onOpenChange={(v) => { if (!v) editDialog.close() }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Edit Employee</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<EmployeeForm
resourceId={editDialog.resourceId}
onSuccess={() => {
editDialog.close()
router.refresh()
}}
/>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,178 @@
"use client"
import { useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Loader2 } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Badge } from "@/shared/components/ui/badge"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
// ── Types ──
export type EmployeeOption = {
value: string
label: string
first_name?: string
last_name?: string
email?: string
type?: string
status?: string
}
// ── Helpers ──
function buildEmployeeOption(item: any): EmployeeOption {
const label =
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
`Employee #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
email: item.email,
type: item.type,
status: item.status,
}
}
function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response
const obj = response as any
if (Array.isArray(obj?.data?.data)) return obj.data.data
if (Array.isArray(obj?.data)) return obj.data
return []
}
function getInitials(opt: EmployeeOption): string {
return (
[opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "E"
)
}
// ── Props ──
type EmployeeComboboxProps = {
value?: EmployeeOption | null
onValueChange: (employee: EmployeeOption | null) => void
disabled?: boolean
placeholder?: string
showClear?: boolean
onBlur?: () => void
"aria-invalid"?: boolean
}
// ── Component ──
export function EmployeeCombobox({
value,
onValueChange,
disabled,
placeholder = "Search by name or email...",
showClear,
onBlur,
"aria-invalid": ariaInvalid,
}: EmployeeComboboxProps) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const { data: options = [], isLoading } = useQuery<EmployeeOption[]>({
queryKey: [EMPLOYEE_ROUTES.INDEX, "employee-select"],
queryFn: async () => {
const res = await api.employees.list()
return extractItems(res).map(buildEmployeeOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((e) =>
[e.first_name, e.last_name, e.email, e.type]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
return (
<div ref={anchorRef}>
<Combobox
value={value}
onValueChange={(val: EmployeeOption | EmployeeOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
onValueChange(single)
}}
disabled={disabled}
onInputValueChange={(val: string, { reason }: { reason: string }) => {
if (reason === "input-change") setInputValue(val)
}}
isItemEqualToValue={(item: EmployeeOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={showClear ?? !!value}
onBlur={onBlur}
aria-invalid={ariaInvalid || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList>
{isLoading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading &&
filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-1.5">
{opt.type && (
<Badge
variant="secondary"
className="h-4 px-1.5 text-xs font-normal capitalize"
>
{opt.type}
</Badge>
)}
{opt.status && (
<span
className={`text-xs capitalize ${opt.status === "active" ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
>
{opt.status}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No employees found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
)
}
@@ -0,0 +1,28 @@
"use client"
import { createContext, useContext } from "react"
type EmployeeContextValue = {
id: string
label: string
}
const EmployeeContext = createContext<EmployeeContextValue | null>(null)
export function EmployeeProvider({
employee,
children,
}: {
employee: EmployeeContextValue
children: React.ReactNode
}) {
return (
<EmployeeContext.Provider value={employee}>
{children}
</EmployeeContext.Provider>
)
}
export function useEmployee() {
return useContext(EmployeeContext)
}
@@ -0,0 +1,159 @@
import {
User,
Mail,
Phone,
Briefcase,
Building2,
Clock,
MapPin,
Calendar,
BadgeCheck,
CircleDot,
} from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
type EmployeeData = {
id?: number
type?: string
first_name?: string
last_name?: string
email?: string
phone?: string
position?: string
designation?: string
salary?: string
wage_type?: string
status?: string
track_attendance?: boolean
notify_owner_when_punch_in_out?: boolean
geo_fence_radius?: string | number | null
department?: { id?: number; name?: string } | null
shop_calender?: { id?: number; title?: string } | null
shop_timing?: { id?: number; title?: string } | null
created_at?: string
updated_at?: string
}
type EmployeeGeneralInfoProps = {
employee: EmployeeData
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ")
return (
<div className="grid gap-6 md:grid-cols-2">
{/* Personal Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="size-4" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
{employee.type && (
<Badge variant="outline" className="capitalize">
{employee.type}
</Badge>
)}
{employee.status && (
<Badge
variant={employee.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{employee.status}
</Badge>
)}
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={Mail} label="Email" value={employee.email} />
<InfoItem icon={Phone} label="Phone" value={employee.phone} />
<InfoItem icon={Briefcase} label="Position" value={employee.position} />
<InfoItem icon={BadgeCheck} label="Designation" value={employee.designation} />
</div>
</CardContent>
</Card>
{/* Work Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-4" />
Work Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem
icon={Building2}
label="Department"
value={employee.department?.name}
/>
<InfoItem
icon={Calendar}
label="Shop Calendar"
value={employee.shop_calender?.title}
/>
<InfoItem
icon={Clock}
label="Shop Timing"
value={employee.shop_timing?.title}
/>
<InfoItem
icon={MapPin}
label="Geo Fence Radius"
value={employee.geo_fence_radius != null ? String(employee.geo_fence_radius) : null}
/>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant={employee.track_attendance ? "default" : "secondary"}>
<CircleDot className="mr-1 size-3" />
{employee.track_attendance ? "Attendance Tracked" : "No Attendance Tracking"}
</Badge>
{employee.notify_owner_when_punch_in_out && (
<Badge variant="outline">
Punch In/Out Notifications
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,292 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { EMPLOYEE_ROUTES } from "@garage/api"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { toast } from "sonner"
import { Save, ShieldCheck } from "lucide-react"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
// ── Permission resource groups ──
const PERMISSION_GROUPS: { label: string; key: string }[] = [
{ label: "Appointments", key: "appointments" },
{ label: "Bills", key: "bills" },
{ label: "Check Point Labels", key: "check_point_labels" },
{ label: "Credit Notes", key: "credit_notes" },
{ label: "Customers", key: "customers" },
{ label: "Departments", key: "departments" },
{ label: "Document Types", key: "document_types" },
{ label: "Employees", key: "employees" },
{ label: "Estimates", key: "estimates" },
{ label: "Expense Items", key: "expense_items" },
{ label: "Expenses", key: "expenses" },
{ label: "Holidays", key: "holidays" },
{ label: "Inspection Categories", key: "inspection_categories" },
{ label: "Inspection Check Points", key: "inspection_check_points" },
{ label: "Inspections", key: "inspections" },
{ label: "Insurance Types", key: "insurance_types" },
{ label: "Inventory Adjustments", key: "inventory_adjustments" },
{ label: "Inventory Categories", key: "inventory_categories" },
{ label: "Invoice Documents", key: "invoice_documents" },
{ label: "Invoice Labels", key: "invoice_labels" },
{ label: "Invoice Notes", key: "invoice_notes" },
{ label: "Invoice Sequences", key: "invoice_sequences" },
{ label: "Invoices", key: "invoices" },
{ label: "Job Cards", key: "job_cards" },
{ label: "Labels", key: "labels" },
{ label: "Labor Rates", key: "labor_rates" },
{ label: "Make & Models", key: "make_and_models" },
{ label: "Parts", key: "parts" },
{ label: "Payment Mades", key: "payment_mades" },
{ label: "Payment Modes", key: "payment_modes" },
{ label: "Payment Received", key: "payment_recieveds" },
{ label: "Payment Terms", key: "payment_terms" },
{ label: "Purchase Orders", key: "purchase_orders" },
{ label: "Quick Notes", key: "quick_notes" },
{ label: "Quick Remarks", key: "quick_remarks" },
{ label: "Reasons", key: "reasons" },
{ label: "Referral Sources", key: "referral_sources" },
{ label: "Service Group Includes", key: "service_group_includes" },
{ label: "Service Group Parts", key: "service_group_parts" },
{ label: "Service Group Pricings", key: "service_group_pricings" },
{ label: "Service Group Services", key: "service_group_services" },
{ label: "Service Groups", key: "service_groups" },
{ label: "Services", key: "services" },
{ label: "Settings", key: "settings" },
{ label: "Shop Calendars", key: "shop_calenders" },
{ label: "Shop Timings", key: "shop_timings" },
{ label: "Shop Types", key: "shop_types" },
{ label: "Task Sections", key: "task_sections" },
{ label: "Task Types", key: "task_types" },
{ label: "Tasks", key: "tasks" },
{ label: "Taxes", key: "taxes" },
{ label: "Time Sheets", key: "time_sheets" },
{ label: "Unit Types", key: "unit_types" },
{ label: "Vehicle Body Types", key: "vehicle_body_types" },
{ label: "Vehicle Colors", key: "vehicle_colors" },
{ label: "Vehicle Documents", key: "vehicle_documents" },
{ label: "Vehicle Fuel Types", key: "vehicle_fuel_types" },
{ label: "Vehicle Mile & Kms", key: "vehicle_mile_and_kms" },
{ label: "Vehicle Transmissions", key: "vehicle_transmissions" },
{ label: "Vehicles", key: "vehicles" },
{ label: "Vendor Credits", key: "vendor_credits" },
{ label: "Vendors", key: "vendors" },
]
const ACTIONS = ["view", "create", "update", "delete"] as const
type PermissionAction = typeof ACTIONS[number]
type PermissionsState = Record<string, boolean>
function buildPermissionsPayload(state: PermissionsState) {
return state
}
function extractPermissions(data: Record<string, unknown> | null | undefined): PermissionsState {
if (!data) return {}
const result: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
const key = `can_${action}_${group.key}`
result[key] = Boolean(data[key])
}
}
return result
}
type EmployeePermissionsFormProps = {
employeeId: string
}
export function EmployeePermissionsForm({ employeeId }: EmployeePermissionsFormProps) {
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = [EMPLOYEE_ROUTES.BY_ID, employeeId]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.employees.getById(employeeId),
})
const employeeData = (data as any)?.data as Record<string, unknown> | undefined
const [permissions, setPermissions] = useState<PermissionsState | null>(null)
const currentPermissions = permissions ?? extractPermissions(employeeData)
const { mutate, isPending } = useMutation({
mutationFn: (payload: PermissionsState) =>
api.employees.updatePermissions(employeeId, payload as never),
onSuccess: () => {
toast.success("Permissions updated successfully")
queryClient.invalidateQueries({ queryKey })
},
onError: () => {
toast.error("Failed to update permissions")
},
})
const handleToggle = (key: string, value: boolean) => {
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
[key]: value,
}))
}
const handleRowToggle = (groupKey: string, checked: boolean) => {
const updates: PermissionsState = {}
for (const action of ACTIONS) {
updates[`can_${action}_${groupKey}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const handleColumnToggle = (action: PermissionAction, checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
updates[`can_${action}_${group.key}`] = checked
}
setPermissions((prev) => ({
...(prev ?? extractPermissions(employeeData)),
...updates,
}))
}
const isRowChecked = (groupKey: string) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${groupKey}`])
const isRowIndeterminate = (groupKey: string) => {
const values = ACTIONS.map((action) => currentPermissions[`can_${action}_${groupKey}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isColumnChecked = (action: PermissionAction) =>
PERMISSION_GROUPS.every((g) => currentPermissions[`can_${action}_${g.key}`])
const isColumnIndeterminate = (action: PermissionAction) => {
const values = PERMISSION_GROUPS.map((g) => currentPermissions[`can_${action}_${g.key}`])
return values.some(Boolean) && !values.every(Boolean)
}
const isAllChecked = PERMISSION_GROUPS.every((g) =>
ACTIONS.every((action) => currentPermissions[`can_${action}_${g.key}`])
)
const isAllIndeterminate = (() => {
const values = PERMISSION_GROUPS.flatMap((g) =>
ACTIONS.map((action) => currentPermissions[`can_${action}_${g.key}`])
)
return values.some(Boolean) && !values.every(Boolean)
})()
const handleToggleAll = (checked: boolean) => {
const updates: PermissionsState = {}
for (const group of PERMISSION_GROUPS) {
for (const action of ACTIONS) {
updates[`can_${action}_${group.key}`] = checked
}
}
setPermissions(updates)
}
if (isLoading) {
return (
<DashboardPage header={null}>
<div className="text-muted-foreground text-sm">Loading permissions...</div>
</DashboardPage>
)
}
return (
<DashboardPage header={null}>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
Permissions
</CardTitle>
<Button
size="sm"
onClick={() => mutate(buildPermissionsPayload(currentPermissions))}
disabled={isPending}
>
<Save className="size-4" />
Save Permissions
</Button>
</CardHeader>
<CardContent className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 pr-4 text-left font-medium">
<div className="flex items-center gap-2">
<Checkbox
className="border-primary"
checked={isAllChecked || (isAllIndeterminate ? "indeterminate" : false)}
onCheckedChange={(v) => handleToggleAll(Boolean(v))}
/>
Resource
</div>
</th>
{ACTIONS.map((action) => (
<th key={action} className="py-2 px-4 text-center font-medium capitalize">
<div className="flex flex-col items-center gap-1">
<Checkbox
className="border border-primary"
checked={isColumnChecked(action) || (isColumnIndeterminate(action) ? "indeterminate" : false)}
onCheckedChange={(v) => handleColumnToggle(action, Boolean(v))}
/>
<Badge variant="outline" className="capitalize text-xs">
{action}
</Badge>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GROUPS.map((group) => (
<tr key={group.key} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="py-2 pr-4">
<div className="flex items-center gap-2">
<Checkbox
className="border border-primary"
checked={isRowChecked(group.key) || (isRowIndeterminate(group.key) ? "indeterminate" : false)}
onCheckedChange={(v) => handleRowToggle(group.key, Boolean(v))}
/>
<span className="text-sm">{group.label}</span>
</div>
</td>
{ACTIONS.map((action) => {
const key = `can_${action}_${group.key}`
return (
<td key={action} className="py-2 px-4 text-center ">
<Checkbox
className="mx-auto border border-primary"
checked={Boolean(currentPermissions[key])}
onCheckedChange={(v) => handleToggle(key, Boolean(v))}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</DashboardPage>
)
}
@@ -0,0 +1,60 @@
"use client"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { FieldShell } from "@/shared/components/form/field-shell"
import { EmployeeCombobox } from "./employee-combobox"
// ── Props ──
export type RhfEmployeeSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfEmployeeSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Employee",
description,
required,
disabled,
placeholder = "Search by name or email...",
}: RhfEmployeeSelectFieldProps<TValues, TName>) {
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
return (
<FieldShell
label={label}
error={error?.message}
description={description}
required={required}
>
<EmployeeCombobox
value={field.value}
onValueChange={(emp) => {
field.onChange(emp ? { value: emp.value, label: emp.label } : null)
}}
disabled={field.disabled}
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
</FieldShell>
)
}