feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments

- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
This commit is contained in:
2026-08-05 13:23:07 +05:30
parent 8e24ed6375
commit c31e23c2b9
36 changed files with 20627 additions and 137 deletions
@@ -170,6 +170,7 @@ export default function EmployeeDetailPage() {
emergencyContactName: employee.emergencyContactName,
emergencyContactRelationship: employee.emergencyContactRelationship,
emergencyContactPhone: employee.emergencyContactPhone,
hireDate: employee.hireDate,
confirmationDate: employee.confirmationDate,
lastWorkingDate: employee.lastWorkingDate,
departmentId: employee.departmentId,
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
@@ -142,7 +142,7 @@ function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set<stri
return new Set(named.map((n) => n.id))
}
export default function TemplateBuilderPage() {
function TemplateBuilderContent() {
const params = useParams<{ id: string }>()
const searchParams = useSearchParams()
const router = useRouter()
@@ -806,3 +806,11 @@ export default function TemplateBuilderPage() {
</div>
)
}
export default function TemplateBuilderPage() {
return (
<Suspense fallback={<Skeleton className="h-[60vh] w-full rounded-2xl" />}>
<TemplateBuilderContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
@@ -16,6 +16,7 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { toast } from "@/components/ui/toast"
@@ -28,7 +29,7 @@ type EditableLine = BundleSaleTemplateLine & { key: string }
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
export default function NewBundleSalePage() {
function NewBundleSaleContent() {
const router = useRouter()
const searchParams = useSearchParams()
const templateFromQuery = searchParams.get("templateId")
@@ -286,3 +287,11 @@ export default function NewBundleSalePage() {
</div>
)
}
export default function NewBundleSalePage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<NewBundleSaleContent />
</Suspense>
)
}
@@ -309,7 +309,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
<div className="text-sm text-muted-foreground">Location: {warehouse?.location ?? "—"}</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
@@ -20,7 +20,7 @@ function formatHeader(key: string) {
.trim()
}
function formatCell(value: unknown) {
function formatCell(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "number") return value.toLocaleString("en-LK", { maximumFractionDigits: 2 })
if (typeof value === "string") {
@@ -1,135 +1,22 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Building2, Save } from "lucide-react"
import { Building2 } from "lucide-react"
import { companyApi } from "@/lib/api/company"
import { errorMessage } from "@/lib/error-map"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { CompanyProfile } from "@/types/company"
import { buttonVariants, Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function CompanyProfilePage() {
const [profile, setProfile] = useState<CompanyProfile | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
companyApi
.getProfile()
.then((res) => {
setProfile(res.data)
setEtag(res.etag)
})
.catch((err) => setError(errorMessage(err)))
}, [])
function patch<K extends keyof CompanyProfile>(key: K, value: CompanyProfile[K]) {
setProfile((prev) => (prev ? { ...prev, [key]: value } : prev))
}
async function save() {
if (!profile || !etag) return
setSaving(true)
setError(null)
try {
const updated = await companyApi.updateProfile(profile, etag)
setProfile(updated.data)
setEtag(updated.etag)
toast.success("Company profile saved", updated.data.legalName)
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
<Building2 className="size-10 text-muted-foreground" />
<div>
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">Invoice header, tax details, logo, and bank information.</p>
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
</div>
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to Settings
</Link>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!error && !profile && <Skeleton className="h-64 w-full" />}
{!error && profile && (
<div className="flex flex-col gap-6 rounded-2xl border p-6">
<div className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Invoice Header</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Legal Name" value={profile.legalName} onChange={(v) => patch("legalName", v)} />
<Field label="Trade Name" value={profile.tradeName ?? ""} onChange={(v) => patch("tradeName", v)} />
<Field label="Logo URL" value={profile.logoUrl ?? ""} onChange={(v) => patch("logoUrl", v)} />
<Field label="Tax Registration No" value={profile.taxRegistrationNo ?? ""} onChange={(v) => patch("taxRegistrationNo", v)} />
<Field label="VAT Registration No" value={profile.vatRegistrationNo ?? ""} onChange={(v) => patch("vatRegistrationNo", v)} />
<Field label="Phone" value={profile.phone ?? ""} onChange={(v) => patch("phone", v)} />
<Field label="Email" value={profile.email ?? ""} onChange={(v) => patch("email", v)} />
<Field label="City" value={profile.city ?? ""} onChange={(v) => patch("city", v)} />
<Field label="Country" value={profile.country ?? ""} onChange={(v) => patch("country", v)} />
<Field label="Address Line 1" value={profile.addressLine1 ?? ""} onChange={(v) => patch("addressLine1", v)} />
<Field label="Address Line 2" value={profile.addressLine2 ?? ""} onChange={(v) => patch("addressLine2", v)} />
</div>
<div className="border-t" />
<div className="flex items-center gap-2">
<Save className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Bank Details</h2>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Bank Name" value={profile.bankName ?? ""} onChange={(v) => patch("bankName", v)} />
<Field label="Bank Branch" value={profile.bankBranch ?? ""} onChange={(v) => patch("bankBranch", v)} />
<Field label="Account Name" value={profile.accountName ?? ""} onChange={(v) => patch("accountName", v)} />
<Field label="Account Number" value={profile.accountNumber ?? ""} onChange={(v) => patch("accountNumber", v)} />
<Field label="SWIFT Code" value={profile.swiftCode ?? ""} onChange={(v) => patch("swiftCode", v)} />
<Field label="Footer Note" value={profile.footerNote ?? ""} onChange={(v) => patch("footerNote", v)} />
</div>
<div className="flex justify-end gap-3">
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" onClick={save} disabled={saving}>
{saving ? "Saving..." : "Save Profile"}
</Button>
</div>
</div>
)}
</div>
)
}
function Field({
label,
value,
onChange,
}: {
label: string
value: string
onChange: (value: string) => void
}) {
return (
<div className="flex flex-col gap-2">
<Label>{label}</Label>
<Input value={value} onChange={(e) => onChange(e.target.value)} />
</div>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { bundleApi } from "@/lib/api/bundles"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { BundleSaleSummary } from "@/types/bundles"
@@ -22,7 +23,7 @@ function statusClass(status: BundleSaleSummary["status"]) {
}
}
export default function BundleBatchPrintPage() {
function BundleBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -88,3 +89,11 @@ export default function BundleBatchPrintPage() {
</div>
)
}
export default function BundleBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<BundleBatchPrintContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesInvoiceSummary } from "@/types/sales"
@@ -22,7 +23,7 @@ function statusClass(status: SalesInvoiceSummary["status"]) {
}
}
export default function SalesInvoiceBatchPrintPage() {
function SalesInvoiceBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -105,3 +106,11 @@ export default function SalesInvoiceBatchPrintPage() {
</div>
)
}
export default function SalesInvoiceBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesInvoiceBatchPrintContent />
</Suspense>
)
}
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
@@ -8,6 +8,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesSlipSummary } from "@/types/sales"
@@ -22,7 +23,7 @@ function statusClass(status: SalesSlipSummary["status"]) {
}
}
export default function SalesSlipBatchPrintPage() {
function SalesSlipBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
@@ -103,3 +104,11 @@ export default function SalesSlipBatchPrintPage() {
</div>
)
}
export default function SalesSlipBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesSlipBatchPrintContent />
</Suspense>
)
}
@@ -8,7 +8,7 @@ import { Plus } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { ApiResult, EntityStatus, PaginationMeta } from "@/types/common"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -27,7 +27,7 @@ interface CodeNamed {
interface Api<T extends CodeNamed> {
list(params: { page: number; pageSize: number }): Promise<{ items: T[]; pagination: PaginationMeta }>
create(request: { code: string; name: string }): Promise<{ value: T }>
create(request: { code: string; name: string }): Promise<ApiResult<T>>
updateStatus(id: number, status: EntityStatus): Promise<void>
}