feat: add payroll, time clocks, and leave request modules
- Implemented PayrollPage for managing payroll runs with CRUD operations. - Created TimeClocksPage for tracking employee clock-ins and clock-outs. - Developed TimeSheetsPage for displaying employee timesheets. - Added EmployeeCertificationForm and EmployeeDocumentForm for managing employee certifications and documents. - Introduced LeaveRequestForm for submitting and managing leave requests. - Added usePermissions hook for UI-side permission checks. - Created LeaveRequestsClient and PayrollClient for API interactions.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Field, FieldLabel, FieldError } from "@/shared/components/ui/field"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").max(150),
|
||||
issuer: z.string().trim().max(150).optional(),
|
||||
certificate_number: z.string().trim().max(100).optional(),
|
||||
issued_date: z.string().optional(),
|
||||
expiry_date: z.string().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export type EmployeeCertificationFormProps = {
|
||||
employeeId: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function EmployeeCertificationForm({ employeeId, onSuccess, onCancel }: EmployeeCertificationFormProps) {
|
||||
const api = useAuthApi()
|
||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "", issuer: "", certificate_number: "",
|
||||
issued_date: "", expiry_date: "", notes: "",
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: FormValues) => {
|
||||
const promise = api.employees.createCertification(employeeId, {
|
||||
name: values.name,
|
||||
issuer: values.issuer || undefined,
|
||||
certificate_number: values.certificate_number || undefined,
|
||||
issued_date: values.issued_date || undefined,
|
||||
expiry_date: values.expiry_date || undefined,
|
||||
notes: values.notes || undefined,
|
||||
file: file ?? undefined,
|
||||
})
|
||||
toast.promise(promise, {
|
||||
loading: "Saving certification...",
|
||||
success: "Certification added",
|
||||
error: (err: any) => err?.message ?? "Failed to save",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
reset()
|
||||
setFile(null)
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form className="grid gap-4" onSubmit={handleSubmit((v) => mutation.mutate(v))}>
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input {...register("name")} placeholder="e.g. ASE A1 Engine Repair" />
|
||||
{errors.name && <FieldError>{errors.name.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Issuer</FieldLabel>
|
||||
<Input {...register("issuer")} placeholder="ASE / SAE / ..." />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Certificate Number</FieldLabel>
|
||||
<Input {...register("certificate_number")} placeholder="Optional" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Issued Date</FieldLabel>
|
||||
<Input type="date" {...register("issued_date")} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Expiry Date</FieldLabel>
|
||||
<Input type="date" {...register("expiry_date")} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Attachment (optional)</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<UploadIcon className="size-4" />
|
||||
Choose file
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{file ? file.name : "No file selected"}</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<Textarea rows={3} {...register("notes")} />
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving..." : "Save Certification"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Field, FieldLabel, FieldError } from "@/shared/components/ui/field"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ value: "contract", label: "Contract" },
|
||||
{ value: "id", label: "ID / Passport" },
|
||||
{ value: "ase", label: "ASE" },
|
||||
{ value: "certification", label: "Certification" },
|
||||
{ value: "other", label: "Other" },
|
||||
] as const
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").max(150),
|
||||
type: z.enum(["contract", "id", "ase", "certification", "other"]),
|
||||
issued_date: z.string().optional(),
|
||||
expiry_date: z.string().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export type EmployeeDocumentFormProps = {
|
||||
employeeId: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function EmployeeDocumentForm({ employeeId, onSuccess, onCancel }: EmployeeDocumentFormProps) {
|
||||
const api = useAuthApi()
|
||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: "", type: "other", issued_date: "", expiry_date: "", notes: "" },
|
||||
})
|
||||
|
||||
const type = watch("type")
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: FormValues) => {
|
||||
if (!file) throw new Error("Select a file to upload.")
|
||||
const promise = api.employees.uploadDocument(employeeId, {
|
||||
file,
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
issued_date: values.issued_date || undefined,
|
||||
expiry_date: values.expiry_date || undefined,
|
||||
notes: values.notes || undefined,
|
||||
})
|
||||
toast.promise(promise, {
|
||||
loading: "Uploading document...",
|
||||
success: "Document uploaded",
|
||||
error: (err: any) => err?.message ?? "Failed to upload",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
reset()
|
||||
setFile(null)
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form className="grid gap-4" onSubmit={handleSubmit((v) => mutation.mutate(v))}>
|
||||
<Field>
|
||||
<FieldLabel>File</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
|
||||
<UploadIcon className="size-4" />
|
||||
Choose file
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">{file ? file.name : "No file selected"}</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
{!file && mutation.isError && (
|
||||
<FieldError>Select a file before uploading.</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input {...register("name")} placeholder="e.g. Employment contract 2026" />
|
||||
{errors.name && <FieldError>{errors.name.message}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Type</FieldLabel>
|
||||
<Select value={type} onValueChange={(v) => setValue("type", v as FormValues["type"], { shouldValidate: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Issued Date</FieldLabel>
|
||||
<Input type="date" {...register("issued_date")} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Expiry Date</FieldLabel>
|
||||
<Input type="date" {...register("expiry_date")} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<Textarea rows={3} {...register("notes")} placeholder="Optional" />
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
import { AlertTriangle, ImageIcon, Plus, Save, UploadIcon } from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfAsyncSelectField,
|
||||
RhfCheckboxField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
@@ -24,27 +28,18 @@ import {
|
||||
STATUS_OPTIONS,
|
||||
TYPE_OPTIONS,
|
||||
WAGE_TYPE_OPTIONS,
|
||||
GENDER_OPTIONS,
|
||||
MARITAL_OPTIONS,
|
||||
} from "./employee.schema"
|
||||
import { EMPLOYEE_ROUTES, DEPARTMENT_ROUTES, SHOP_TIMING_ROUTES, SHOP_CALENDAR_ROUTES, GEO_ROUTES } from "@garage/api"
|
||||
|
||||
// ── Constants ──
|
||||
const titleCase = (v: string) => v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
|
||||
const STATUS_SELECT_OPTIONS = STATUS_OPTIONS.map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const TYPE_SELECT_OPTIONS = TYPE_OPTIONS.map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
const WAGE_TYPE_SELECT_OPTIONS = WAGE_TYPE_OPTIONS.map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))
|
||||
|
||||
// ── Props ──
|
||||
const STATUS_SELECT_OPTIONS = STATUS_OPTIONS.map((v) => ({ value: v, label: titleCase(v) }))
|
||||
const TYPE_SELECT_OPTIONS = TYPE_OPTIONS.map((v) => ({ value: v, label: titleCase(v) }))
|
||||
const WAGE_TYPE_SELECT_OPTIONS = WAGE_TYPE_OPTIONS.map((v) => ({ value: v, label: v }))
|
||||
const GENDER_SELECT_OPTIONS = GENDER_OPTIONS.map((v) => ({ value: v, label: titleCase(v) }))
|
||||
const MARITAL_SELECT_OPTIONS = MARITAL_OPTIONS.map((v) => ({ value: v, label: titleCase(v) }))
|
||||
|
||||
export type EmployeeFormProps = {
|
||||
resourceId?: string | null
|
||||
@@ -52,8 +47,6 @@ export type EmployeeFormProps = {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
// ── Default values ──
|
||||
|
||||
const DEFAULT_VALUES: EmployeeFormValues = {
|
||||
department: null,
|
||||
country: null,
|
||||
@@ -73,12 +66,28 @@ const DEFAULT_VALUES: EmployeeFormValues = {
|
||||
track_attendance: true,
|
||||
notify_owner_when_punch_in_out: false,
|
||||
geo_fence_radius: null,
|
||||
address: "",
|
||||
date_of_birth: "",
|
||||
gender: null,
|
||||
marital_status: null,
|
||||
national_id: "",
|
||||
hire_date: "",
|
||||
emergency_contact_name: "",
|
||||
emergency_contact_phone: "",
|
||||
bank_name: "",
|
||||
bank_account_number: "",
|
||||
bank_iban: "",
|
||||
hourly_rate: null,
|
||||
commission_rate: null,
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
|
||||
function mapToFormValues(data: unknown): EmployeeFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
const formatDate = (v: unknown): string => {
|
||||
if (!v) return ""
|
||||
const s = String(v)
|
||||
return s.length >= 10 ? s.slice(0, 10) : s
|
||||
}
|
||||
|
||||
return {
|
||||
department: toRelation(d.department_id, d.department?.name),
|
||||
@@ -99,6 +108,19 @@ function mapToFormValues(data: unknown): EmployeeFormValues {
|
||||
track_attendance: d.track_attendance ?? true,
|
||||
notify_owner_when_punch_in_out: d.notify_owner_when_punch_in_out ?? false,
|
||||
geo_fence_radius: d.geo_fence_radius ?? null,
|
||||
address: d.address || "",
|
||||
date_of_birth: formatDate(d.date_of_birth),
|
||||
gender: d.gender ?? null,
|
||||
marital_status: d.marital_status ?? null,
|
||||
national_id: d.national_id || "",
|
||||
hire_date: formatDate(d.hire_date),
|
||||
emergency_contact_name: d.emergency_contact_name || "",
|
||||
emergency_contact_phone: d.emergency_contact_phone || "",
|
||||
bank_name: d.bank_name || "",
|
||||
bank_account_number: d.bank_account_number || "",
|
||||
bank_iban: d.bank_iban || "",
|
||||
hourly_rate: d.hourly_rate ?? null,
|
||||
commission_rate: d.commission_rate ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +134,7 @@ function mapFormToPayload(values: EmployeeFormValues) {
|
||||
first_name: values.first_name,
|
||||
last_name: values.last_name,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
password: values.password || undefined,
|
||||
phone: values.phone || undefined,
|
||||
designation: values.designation || undefined,
|
||||
salary: values.salary ?? undefined,
|
||||
@@ -122,11 +144,22 @@ function mapFormToPayload(values: EmployeeFormValues) {
|
||||
track_attendance: values.track_attendance,
|
||||
notify_owner_when_punch_in_out: values.notify_owner_when_punch_in_out,
|
||||
geo_fence_radius: values.geo_fence_radius ?? undefined,
|
||||
address: values.address || undefined,
|
||||
date_of_birth: values.date_of_birth || undefined,
|
||||
gender: values.gender ?? undefined,
|
||||
marital_status: values.marital_status ?? undefined,
|
||||
national_id: values.national_id || undefined,
|
||||
hire_date: values.hire_date || undefined,
|
||||
emergency_contact_name: values.emergency_contact_name || undefined,
|
||||
emergency_contact_phone: values.emergency_contact_phone || undefined,
|
||||
bank_name: values.bank_name || undefined,
|
||||
bank_account_number: values.bank_account_number || undefined,
|
||||
bank_iban: values.bank_iban || undefined,
|
||||
hourly_rate: values.hourly_rate ?? undefined,
|
||||
commission_rate: values.commission_rate ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mapOption for async selects ──
|
||||
|
||||
const mapLookupOption = (item: any) => ({
|
||||
value: String(item.id),
|
||||
label: item.name ?? item.title ?? String(item.id),
|
||||
@@ -134,7 +167,71 @@ const mapLookupOption = (item: any) => ({
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
// ── Component ──
|
||||
function SectionHeading({ title, description }: { title: string; description?: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<Separator className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarUpload({ employeeId, initialUrl }: { employeeId: string; initialUrl?: string | null }) {
|
||||
const api = useAuthApi()
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(initialUrl ?? null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const res: any = await api.employees.uploadAvatar(employeeId, file)
|
||||
const url = res?.data?.avatar_url ?? URL.createObjectURL(file)
|
||||
setPreviewUrl(url)
|
||||
toast.success("Avatar updated")
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Failed to upload avatar")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
e.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar size="lg">
|
||||
{previewUrl ? <AvatarImage src={previewUrl} alt="Avatar" /> : null}
|
||||
<AvatarFallback>
|
||||
<ImageIcon className="size-5 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<UploadIcon className="size-4" />
|
||||
{uploading ? "Uploading..." : "Upload photo"}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-muted-foreground">JPG, PNG, WEBP. Max 5 MB.</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFormProps) {
|
||||
const api = useAuthApi()
|
||||
@@ -168,6 +265,9 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
|
||||
},
|
||||
})
|
||||
|
||||
const initialAvatarUrl = (initialData as any)?.avatar_url
|
||||
?? ((initialData as any)?.avatar_path ? `/storage/${(initialData as any).avatar_path}` : null)
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
@@ -181,19 +281,67 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
{/* Personal */}
|
||||
<SectionHeading title="Personal" />
|
||||
|
||||
{isEditing && resourceId ? (
|
||||
<AvatarUpload employeeId={resourceId} initialUrl={initialAvatarUrl} />
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="first_name" label="First Name" placeholder="Jane" required />
|
||||
<RhfTextField name="last_name" label="Last Name" placeholder="Smith" required />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" />
|
||||
<RhfTextField name="password" label="Password" placeholder="At least 6 characters" type="password" required />
|
||||
<RhfTextField name="date_of_birth" label="Date of Birth" type="date" />
|
||||
<RhfTextField name="national_id" label="National ID" placeholder="A1234567" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="phone" label="Phone" placeholder="0501234567" type="tel" />
|
||||
<RhfTextField name="designation" label="Designation" placeholder="Technician" />
|
||||
<RhfSelectField name="gender" label="Gender" placeholder="Select gender" options={GENDER_SELECT_OPTIONS} />
|
||||
<RhfSelectField name="marital_status" label="Marital Status" placeholder="Select status" options={MARITAL_SELECT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<SectionHeading title="Contact" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="email" label="Email" placeholder="jane@example.com" type="email" required />
|
||||
<RhfTextField name="password" label="Password" placeholder={isEditing ? "Leave blank to keep" : "At least 6 characters"} type="password" required={!isEditing} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="phone" label="Phone" placeholder="+971501234567" type="tel" />
|
||||
<RhfAsyncSelectField
|
||||
name="country"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
queryKey={[GEO_ROUTES.COUNTRIES]}
|
||||
listFn={() => api.geo.countries()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RhfTextareaField name="address" label="Address" placeholder="Street, city, region..." rows={2} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="emergency_contact_name" label="Emergency Contact Name" placeholder="Full name" />
|
||||
<RhfTextField name="emergency_contact_phone" label="Emergency Contact Phone" type="tel" />
|
||||
</div>
|
||||
|
||||
{/* Employment */}
|
||||
<SectionHeading title="Employment" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="hire_date" label="Hire Date" type="date" />
|
||||
<RhfTextField name="designation" label="Designation" placeholder="Senior Technician" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="type" label="Type" placeholder="Select type" options={TYPE_SELECT_OPTIONS} />
|
||||
<RhfSelectField name="status" label="Status" placeholder="Select status" options={STATUS_SELECT_OPTIONS} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -209,41 +357,32 @@ export function EmployeeForm({ resourceId, initialData, onSuccess }: EmployeeFor
|
||||
<RhfTextField name="role_id" label="Role ID" placeholder="1" type="number" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
options={STATUS_SELECT_OPTIONS}
|
||||
/>
|
||||
<RhfSelectField
|
||||
name="type"
|
||||
label="Type"
|
||||
placeholder="Select type"
|
||||
options={TYPE_SELECT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
{/* Compensation */}
|
||||
<SectionHeading title="Compensation" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField name="wage_type" label="Wage Type" placeholder="Select wage type" options={WAGE_TYPE_SELECT_OPTIONS} />
|
||||
<RhfTextField name="salary" label="Salary" placeholder="0" type="number" />
|
||||
<RhfSelectField
|
||||
name="wage_type"
|
||||
label="Wage Type"
|
||||
placeholder="Select wage type"
|
||||
options={WAGE_TYPE_SELECT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="country"
|
||||
label="Country"
|
||||
placeholder="Select country"
|
||||
queryKey={[GEO_ROUTES.COUNTRIES]}
|
||||
listFn={() => api.geo.countries()}
|
||||
mapOption={mapLookupOption}
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
<RhfTextField name="hourly_rate" label="Hourly Rate" placeholder="0.00" type="number" />
|
||||
<RhfTextField name="commission_rate" label="Commission (%)" placeholder="0" type="number" />
|
||||
</div>
|
||||
|
||||
{/* Bank */}
|
||||
<SectionHeading title="Bank" description="Used for payroll payouts." />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="bank_name" label="Bank Name" placeholder="Bank of ..." />
|
||||
<RhfTextField name="bank_account_number" label="Account Number" placeholder="000123456789" />
|
||||
</div>
|
||||
<RhfTextField name="bank_iban" label="IBAN" placeholder="AE00 0000 0000 0000 0000 000" />
|
||||
|
||||
{/* Tracking */}
|
||||
<SectionHeading title="Attendance Tracking" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfAsyncSelectField
|
||||
name="shop_calender"
|
||||
label="Shop Calendar"
|
||||
|
||||
@@ -9,6 +9,15 @@ import {
|
||||
Calendar,
|
||||
BadgeCheck,
|
||||
CircleDot,
|
||||
Cake,
|
||||
Heart,
|
||||
IdCard,
|
||||
Home,
|
||||
PhoneCall,
|
||||
Landmark,
|
||||
Wallet,
|
||||
PercentIcon,
|
||||
DollarSign,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
@@ -16,6 +25,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { formatEnum } from "@/shared/utils/formatters"
|
||||
@@ -38,6 +48,22 @@ type EmployeeData = {
|
||||
department?: { id?: number; name?: string } | null
|
||||
shop_calender?: { id?: number; title?: string } | null
|
||||
shop_timing?: { id?: number; title?: string } | null
|
||||
role?: { id?: number; name?: string } | null
|
||||
avatar_url?: string | null
|
||||
avatar_path?: string | null
|
||||
address?: string | null
|
||||
date_of_birth?: string | null
|
||||
gender?: string | null
|
||||
marital_status?: string | null
|
||||
national_id?: string | null
|
||||
hire_date?: string | null
|
||||
emergency_contact_name?: string | null
|
||||
emergency_contact_phone?: string | null
|
||||
bank_name?: string | null
|
||||
bank_account_number?: string | null
|
||||
bank_iban?: string | null
|
||||
hourly_rate?: string | number | null
|
||||
commission_rate?: string | number | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -46,6 +72,19 @@ type EmployeeGeneralInfoProps = {
|
||||
employee: EmployeeData
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string | null {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function initialsOf(first?: string | null, last?: string | null) {
|
||||
const f = (first ?? "").trim()[0] ?? ""
|
||||
const l = (last ?? "").trim()[0] ?? ""
|
||||
return (f + l).toUpperCase() || "?"
|
||||
}
|
||||
|
||||
function InfoItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -53,7 +92,7 @@ function InfoItem({
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value?: string | null
|
||||
value?: string | number | null
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -63,7 +102,7 @@ function InfoItem({
|
||||
<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>}
|
||||
{value != null && value !== "" ? value : <span className="text-muted-foreground">—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,6 +111,7 @@ function InfoItem({
|
||||
|
||||
export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
|
||||
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ")
|
||||
const avatarSrc = employee.avatar_url ?? (employee.avatar_path ? `/storage/${employee.avatar_path}` : null)
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
@@ -84,61 +124,77 @@ export function EmployeeGeneralInfo({ employee }: EmployeeGeneralInfoProps) {
|
||||
</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">
|
||||
{formatEnum(employee.type)}
|
||||
</Badge>
|
||||
)}
|
||||
{employee.status && (
|
||||
<Badge
|
||||
variant={employee.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{formatEnum(employee.status)}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="lg">
|
||||
{avatarSrc ? <AvatarImage src={avatarSrc} alt={fullName} /> : null}
|
||||
<AvatarFallback>{initialsOf(employee.first_name, employee.last_name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{fullName || "Unknown"}</Badge>
|
||||
{employee.type && (
|
||||
<Badge variant="outline">
|
||||
{formatEnum(employee.type)}
|
||||
</Badge>
|
||||
)}
|
||||
{employee.status && (
|
||||
<Badge
|
||||
variant={employee.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{formatEnum(employee.status)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</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} />
|
||||
<InfoItem icon={Cake} label="Date of Birth" value={formatDate(employee.date_of_birth)} />
|
||||
<InfoItem icon={Heart} label="Marital Status" value={employee.marital_status ? formatEnum(employee.marital_status) : null} />
|
||||
<InfoItem icon={User} label="Gender" value={employee.gender ? formatEnum(employee.gender) : null} />
|
||||
<InfoItem icon={IdCard} label="National ID" value={employee.national_id} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Home} label="Address" value={employee.address} />
|
||||
<InfoItem icon={PhoneCall} label="Emergency Contact" value={[employee.emergency_contact_name, employee.emergency_contact_phone].filter(Boolean).join(" • ")} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Work Details */}
|
||||
{/* Employment + Compensation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-4" />
|
||||
Work Details
|
||||
Employment
|
||||
</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}
|
||||
/>
|
||||
<InfoItem icon={Briefcase} label="Designation" value={employee.designation} />
|
||||
<InfoItem icon={Calendar} label="Hire Date" value={formatDate(employee.hire_date)} />
|
||||
<InfoItem icon={Building2} label="Department" value={employee.department?.name} />
|
||||
<InfoItem icon={BadgeCheck} label="Role" value={employee.role?.name} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={DollarSign} label="Salary" value={employee.salary ? Number(employee.salary).toFixed(2) : null} />
|
||||
<InfoItem icon={Wallet} label="Wage Type" value={employee.wage_type} />
|
||||
<InfoItem icon={Clock} label="Hourly Rate" value={employee.hourly_rate != null ? Number(employee.hourly_rate).toFixed(2) : null} />
|
||||
<InfoItem icon={PercentIcon} label="Commission %" value={employee.commission_rate != null ? Number(employee.commission_rate).toFixed(2) : null} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InfoItem icon={Landmark} label="Bank" value={employee.bank_name} />
|
||||
<InfoItem icon={Wallet} label="Account #" value={employee.bank_account_number} />
|
||||
<InfoItem icon={Landmark} label="IBAN" value={employee.bank_iban} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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 (m)" value={employee.geo_fence_radius != null ? String(employee.geo_fence_radius) : null} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -25,9 +25,22 @@ const optionalIntegerIdField = z.preprocess(
|
||||
z.number().int().positive().nullable(),
|
||||
)
|
||||
|
||||
const optionalTrimmedString = (max: number, msg?: string) =>
|
||||
z.preprocess(
|
||||
(value) => (typeof value !== "string" ? value : value.trim() === "" ? undefined : value.trim()),
|
||||
z.string().max(max, msg ?? `Must be at most ${max} characters`).optional(),
|
||||
)
|
||||
|
||||
const STATUS_OPTIONS = ["active", "inactive"] as const
|
||||
const TYPE_OPTIONS = EmployeeType
|
||||
const WAGE_TYPE_OPTIONS = WageType
|
||||
const GENDER_OPTIONS = ["male", "female"] as const
|
||||
const MARITAL_OPTIONS = ["single", "married"] as const
|
||||
|
||||
const optionalDate = z.preprocess(
|
||||
(value) => (typeof value !== "string" || value.trim() === "" ? undefined : value),
|
||||
z.string().optional(),
|
||||
)
|
||||
|
||||
const employeeFormSchema = z.object({
|
||||
department: relationFieldSchema,
|
||||
@@ -39,8 +52,8 @@ const employeeFormSchema = z.object({
|
||||
last_name: z.string().trim().min(1, "Last name is required").max(50, "Last name must be at most 50 characters"),
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address").max(100, "Email must be at most 100 characters"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
phone: z.string().trim().max(30, "Phone must be at most 30 characters").optional().or(z.literal("")),
|
||||
designation: z.string().trim().max(50, "Designation must be at most 50 characters").optional().or(z.literal("")),
|
||||
phone: optionalTrimmedString(30),
|
||||
designation: optionalTrimmedString(50),
|
||||
salary: optionalNumericField,
|
||||
wage_type: z.enum(WAGE_TYPE_OPTIONS).nullable(),
|
||||
status: z.enum(STATUS_OPTIONS),
|
||||
@@ -48,9 +61,32 @@ const employeeFormSchema = z.object({
|
||||
track_attendance: z.boolean(),
|
||||
notify_owner_when_punch_in_out: z.boolean(),
|
||||
geo_fence_radius: optionalNumericField,
|
||||
|
||||
// Extended profile fields
|
||||
address: optionalTrimmedString(500),
|
||||
date_of_birth: optionalDate,
|
||||
gender: z.enum(GENDER_OPTIONS).nullable().optional(),
|
||||
marital_status: z.enum(MARITAL_OPTIONS).nullable().optional(),
|
||||
national_id: optionalTrimmedString(50),
|
||||
hire_date: optionalDate,
|
||||
emergency_contact_name: optionalTrimmedString(100),
|
||||
emergency_contact_phone: optionalTrimmedString(30),
|
||||
bank_name: optionalTrimmedString(100),
|
||||
bank_account_number: optionalTrimmedString(50),
|
||||
bank_iban: optionalTrimmedString(50),
|
||||
hourly_rate: optionalNumericField,
|
||||
commission_rate: optionalNumericField,
|
||||
})
|
||||
|
||||
type EmployeeFormValues = z.infer<typeof employeeFormSchema>
|
||||
|
||||
export { employeeFormSchema, relationFieldSchema, STATUS_OPTIONS, TYPE_OPTIONS, WAGE_TYPE_OPTIONS }
|
||||
export {
|
||||
employeeFormSchema,
|
||||
relationFieldSchema,
|
||||
STATUS_OPTIONS,
|
||||
TYPE_OPTIONS,
|
||||
WAGE_TYPE_OPTIONS,
|
||||
GENDER_OPTIONS,
|
||||
MARITAL_OPTIONS,
|
||||
}
|
||||
export type { EmployeeFormValues }
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus, Save } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfSelectField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { RhfEmployeeSelectField } from "@/modules/employees/rhf-employee-select-field"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { LEAVE_REQUEST_ROUTES } from "@garage/api"
|
||||
|
||||
import { z } from "zod"
|
||||
|
||||
const LEAVE_TYPES = ["annual", "sick", "unpaid", "maternity", "paternity", "other"] as const
|
||||
|
||||
const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).nullable()
|
||||
|
||||
const schema = z.object({
|
||||
employee: relationFieldSchema.refine((v) => !!v?.value, "Employee is required"),
|
||||
leave_type: z.enum(LEAVE_TYPES),
|
||||
start_date: z.string().min(1, "Start date is required"),
|
||||
end_date: z.string().min(1, "End date is required"),
|
||||
reason: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
type LeaveFormValues = z.infer<typeof schema>
|
||||
|
||||
const DEFAULT_VALUES: LeaveFormValues = {
|
||||
employee: null,
|
||||
leave_type: "annual",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
reason: "",
|
||||
}
|
||||
|
||||
const TYPE_SELECT_OPTIONS = LEAVE_TYPES.map((v) => ({
|
||||
value: v,
|
||||
label: v.replace(/^./, (c) => c.toUpperCase()),
|
||||
}))
|
||||
|
||||
export type LeaveRequestFormProps = {
|
||||
resourceId?: string | null
|
||||
initialData?: unknown
|
||||
onSuccess?: () => void
|
||||
/** Pre-fill the employee select when opened from an employee detail page. */
|
||||
presetEmployee?: { id: number | string; label: string } | null
|
||||
}
|
||||
|
||||
function mapToFormValues(data: unknown, preset?: { id: number | string; label: string } | null): LeaveFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
const empLabel = d.employee
|
||||
? `${d.employee.first_name ?? ""} ${d.employee.last_name ?? ""}`.trim()
|
||||
: ""
|
||||
|
||||
const formatDate = (v: unknown) => {
|
||||
if (!v || typeof v !== "string") return ""
|
||||
return v.length >= 10 ? v.slice(0, 10) : v
|
||||
}
|
||||
|
||||
return {
|
||||
employee: d.employee_id
|
||||
? toRelation(d.employee_id, empLabel || `Employee #${d.employee_id}`)
|
||||
: (preset ? toRelation(preset.id, preset.label) : null),
|
||||
leave_type: d.leave_type || "annual",
|
||||
start_date: formatDate(d.start_date),
|
||||
end_date: formatDate(d.end_date),
|
||||
reason: d.reason || "",
|
||||
}
|
||||
}
|
||||
|
||||
export function LeaveRequestForm({ resourceId, initialData, onSuccess, presetEmployee }: LeaveRequestFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<LeaveFormValues, any>({
|
||||
schema,
|
||||
defaultValues: presetEmployee
|
||||
? { ...DEFAULT_VALUES, employee: toRelation(presetEmployee.id, presetEmployee.label) }
|
||||
: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
initialize: (id) => api.leaveRequests.show(id),
|
||||
queryKey: [LEAVE_REQUEST_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues: (data) => mapToFormValues(data, presetEmployee),
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: LeaveFormValues) => {
|
||||
const payload = {
|
||||
employee_id: toId(values.employee) ?? 0,
|
||||
leave_type: values.leave_type,
|
||||
start_date: values.start_date,
|
||||
end_date: values.end_date,
|
||||
reason: values.reason || undefined,
|
||||
}
|
||||
const promise = isEditing && resourceId
|
||||
? api.leaveRequests.update(resourceId, payload)
|
||||
: api.leaveRequests.create(payload as any)
|
||||
toast.promise(promise, {
|
||||
loading: isEditing ? "Updating..." : "Submitting...",
|
||||
success: isEditing ? "Leave request updated" : "Leave request submitted",
|
||||
error: isEditing ? "Failed to update" : "Failed to submit",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>{isEditing ? "Failed to update" : "Failed to submit"}</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<RhfEmployeeSelectField name="employee" label="Employee" required />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfSelectField
|
||||
name="leave_type"
|
||||
label="Leave Type"
|
||||
placeholder="Select type"
|
||||
options={TYPE_SELECT_OPTIONS}
|
||||
/>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="start_date" label="Start Date" type="date" required />
|
||||
<RhfTextField name="end_date" label="End Date" type="date" required />
|
||||
</div>
|
||||
|
||||
<RhfTextareaField name="reason" label="Reason" rows={3} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
{isEditing ? <Save /> : <Plus />}
|
||||
{isPending ? "Saving..." : (isEditing ? "Update" : "Submit Request")}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Plus } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextField,
|
||||
RhfTextareaField,
|
||||
} from "@/shared/components/form"
|
||||
import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
const schema = z.object({
|
||||
period_start: z.string().min(1, "Required"),
|
||||
period_end: z.string().min(1, "Required"),
|
||||
reference: z.string().max(50).optional(),
|
||||
note: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
type PayrollRunFormValues = z.infer<typeof schema>
|
||||
|
||||
function firstOfMonth() {
|
||||
const d = new Date()
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1).toISOString().slice(0, 10)
|
||||
}
|
||||
function lastOfMonth() {
|
||||
const d = new Date()
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export type PayrollRunFormProps = {
|
||||
onSuccess?: (runId?: string) => void
|
||||
}
|
||||
|
||||
export function PayrollRunForm({ onSuccess }: PayrollRunFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const form = useForm<PayrollRunFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
period_start: firstOfMonth(),
|
||||
period_end: lastOfMonth(),
|
||||
reference: "",
|
||||
note: "",
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: PayrollRunFormValues) => {
|
||||
const payload = {
|
||||
period_start: values.period_start,
|
||||
period_end: values.period_end,
|
||||
reference: values.reference || undefined,
|
||||
note: values.note || undefined,
|
||||
}
|
||||
const promise = api.payroll.create(payload)
|
||||
toast.promise(promise, {
|
||||
loading: "Creating run + generating entries...",
|
||||
success: "Payroll run created",
|
||||
error: "Failed to create run",
|
||||
})
|
||||
return promise
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const runId = res?.data?.id ?? res?.id
|
||||
form.reset()
|
||||
onSuccess?.(runId ? String(runId) : undefined)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Rhform form={form} onSubmit={(values) => mutate(values)}>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="me-2 h-4 w-4" />
|
||||
<AlertTitle>Failed to create payroll run</AlertTitle>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<RhfTextField name="period_start" label="Period Start" type="date" required />
|
||||
<RhfTextField name="period_end" label="Period End" type="date" required />
|
||||
</div>
|
||||
|
||||
<RhfTextField name="reference" label="Reference" placeholder="Auto if blank" />
|
||||
|
||||
<RhfTextareaField name="note" label="Note" rows={2} />
|
||||
|
||||
<Button type="submit" variant="default" disabled={isPending}>
|
||||
<Plus />
|
||||
{isPending ? "Creating..." : "Create & Generate"}
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
</Rhform>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user