price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import { Check, CircleDashed, ShieldCheck, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { ApprovalStep, PriceAdjustmentStatus } from "@/types/approvals"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
interface ApprovalPanelProps {
/** "stock adjustment" / "price adjustment" — used in the wording. */
what: string
requiredApprovals: number
/** The approval it waits for (1 or 2); null when it's no longer pending. */
pendingLevel: number | null
history: ApprovalStep[]
requestedByLabel: string
requestedAt: string
canApprove: boolean
whyNot: string | null
canWithdraw: boolean
/** What happens on the final approval, e.g. "Stock moves and the ledger is posted." */
finalEffect: string
outcome: "pending" | "applied" | "rejected" | "withdrawn"
outcomeAt?: string | null
rejectReason?: string | null
onApprove: (comment: string | undefined) => Promise<void>
onReject: (reason: string) => Promise<void>
onWithdraw: () => Promise<void>
}
/**
* The approval trail and the actions for the current user (docs/DUAL_APPROVAL_PLAN.md §10): who
* requested it, each level's decision, and Approve / Reject / Withdraw when the user may.
*/
export function ApprovalPanel(props: ApprovalPanelProps) {
const { what, requiredApprovals, pendingLevel, history, canApprove, whyNot, canWithdraw, outcome } = props
const [busy, setBusy] = useState<"approve" | "reject" | "withdraw" | null>(null)
const [comment, setComment] = useState("")
const [rejectOpen, setRejectOpen] = useState(false)
const [reason, setReason] = useState("")
const [reasonError, setReasonError] = useState<string | null>(null)
const isFinal = pendingLevel !== null && pendingLevel >= requiredApprovals
const levels = Array.from({ length: requiredApprovals }, (_, i) => i + 1)
async function run(kind: "approve" | "withdraw", action: () => Promise<void>) {
setBusy(kind)
try {
await action()
setComment("")
} catch {
// The caller has already shown the error.
} finally {
setBusy(null)
}
}
async function reject() {
if (!reason.trim()) {
setReasonError("Give a reason")
return
}
setBusy("reject")
try {
await props.onReject(reason.trim())
setRejectOpen(false)
setReason("")
} catch {
// The caller has already shown the error; keep the dialog open to retry.
} finally {
setBusy(null)
}
}
if (requiredApprovals === 0) {
return (
<div className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm">
<ShieldCheck className="size-4" />
Applied without approval (approvals were switched off when it was made).
</div>
)
}
return (
<div className="flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-sm sm:p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">Approval</h2>
<Badge variant="outline" className="border-transparent bg-muted text-xs">
{requiredApprovals === 2 ? "Two approvals required" : "One approval required"}
</Badge>
</div>
<ol className="flex flex-col gap-3">
<Step
state="done"
title={`Requested by ${props.requestedByLabel}`}
detail={new Date(props.requestedAt).toLocaleString()}
/>
{levels.map((level) => {
const step = history.find((s) => s.level === level)
if (step) {
return (
<Step
key={level}
state={step.decision === "Approved" ? "done" : "rejected"}
title={`${step.decision === "Approved" ? "Approved" : "Rejected"} (${ordinal(level)} approval) by ${step.decidedByName ?? `#${step.decidedBy}`}`}
detail={`${new Date(step.decidedAt).toLocaleString()}${step.comment ? ` — “${step.comment}”` : ""}`}
/>
)
}
const waiting = outcome === "pending" && pendingLevel === level
return (
<Step
key={level}
state={waiting ? "waiting" : "idle"}
title={`${ordinal(level)} approval`}
detail={waiting ? (level === 2 ? "Waiting — must be a different person from the first approver" : "Waiting for an approver") : "—"}
/>
)
})}
</ol>
{outcome === "rejected" && props.rejectReason && (
<p className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">Rejected: {props.rejectReason}</p>
)}
{outcome === "applied" && (
<p className="rounded-lg bg-success/10 p-3 text-sm text-success">
Fully approved{props.outcomeAt ? ` and applied ${new Date(props.outcomeAt).toLocaleString()}` : ""}.
</p>
)}
{outcome === "withdrawn" && <p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">Withdrawn by the requester.</p>}
{outcome === "pending" && (
<div className="flex flex-col gap-3 border-t pt-4">
{canApprove ? (
<>
<Field>
<FieldLabel htmlFor="approval-comment">Comment (optional)</FieldLabel>
<Textarea id="approval-comment" rows={2} maxLength={250} value={comment} onChange={(e) => setComment(e.target.value)} />
</Field>
<p className="text-xs text-muted-foreground">
{isFinal ? `This is the final approval. ${props.finalEffect}` : "After you approve, a second, different person must approve it too."}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => { setReasonError(null); setRejectOpen(true) }} disabled={busy !== null}>
<X className="size-4" />
Reject
</Button>
<Button onClick={() => run("approve", () => props.onApprove(comment.trim() || undefined))} disabled={busy !== null}>
<Check className="size-4" />
{busy === "approve" ? "Approving…" : isFinal ? "Approve & apply" : "Approve"}
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{whyNot ?? "You can't approve this."}</p>
)}
{canWithdraw && (
<Button variant="ghost" size="sm" className="self-end" onClick={() => run("withdraw", props.onWithdraw)} disabled={busy !== null}>
<Undo2 className="size-4" />
{busy === "withdraw" ? "Withdrawing…" : "Withdraw my request"}
</Button>
)}
</div>
)}
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reject this {what}</DialogTitle>
<DialogDescription>Nothing will be applied. The requester is told your reason.</DialogDescription>
</DialogHeader>
<Field data-invalid={!!reasonError}>
<FieldLabel htmlFor="reject-reason" required>Reason</FieldLabel>
<Textarea id="reject-reason" rows={3} maxLength={250} value={reason} onChange={(e) => { setReason(e.target.value); setReasonError(null) }} />
<FieldError errors={[reasonError ? { message: reasonError } : undefined]} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOpen(false)} disabled={busy === "reject"}>Keep it</Button>
<Button variant="destructive" onClick={reject} disabled={busy === "reject"}>{busy === "reject" ? "Rejecting…" : "Reject"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ordinal(level: number) {
return level === 1 ? "1st" : level === 2 ? "2nd" : `${level}th`
}
function Step({ state, title, detail }: { state: "done" | "rejected" | "waiting" | "idle"; title: string; detail: string }) {
const Icon = state === "done" ? Check : state === "rejected" ? X : CircleDashed
return (
<li className="flex items-start gap-3">
<span
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full",
state === "done" && "bg-success/15 text-success",
state === "rejected" && "bg-destructive/15 text-destructive",
state === "waiting" && "bg-warning/15 text-warning",
state === "idle" && "bg-muted text-muted-foreground"
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0">
<p className={cn("text-sm font-medium", state === "idle" ? "text-muted-foreground" : "text-foreground")}>{title}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
</li>
)
}
const priceLabel: Record<PriceAdjustmentStatus, string> = {
PendingApproval: "Pending",
PendingSecondApproval: "Pending 2nd",
Approved: "Applied",
Rejected: "Rejected",
Cancelled: "Withdrawn",
}
export function PriceAdjustmentStatusBadge({ status }: { status: PriceAdjustmentStatus }) {
const tone =
status === "Approved" ? "bg-success/10 text-success"
: status === "Rejected" ? "bg-destructive/10 text-destructive"
: status === "Cancelled" ? "bg-muted text-muted-foreground"
: "bg-warning/10 text-warning"
return (
<Badge variant="outline" className={cn("h-6 w-28 justify-center border-transparent px-2.5 py-1 text-sm", tone)}>
{priceLabel[status]}
</Badge>
)
}
@@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardCheck } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { ApprovalDocType } from "@/types/approvals"
import { buttonVariants } from "@/components/ui/button"
/**
* "Approval queue" button for list pages: opens the Approvals inbox and shows how many requests of
* this kind are waiting for the current user (docs/DUAL_APPROVAL_PLAN.md §10).
*/
export function ApprovalQueueButton({ docType, className }: { docType?: ApprovalDocType; className?: string }) {
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
approvalsApi
.pending()
.then((p) => setCount(p.items.filter((i) => i.canApprove && (!docType || i.docType === docType)).length))
.catch(() => setCount(null))
}, [docType])
return (
<Link href="/dashboard/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto", className)}>
<ClipboardCheck className="size-5" />
Approval queue
{count !== null && count > 0 && (
<span className="rounded-full bg-primary px-2 text-xs font-semibold leading-5 text-primary-foreground tabular-nums">{count}</span>
)}
</Link>
)
}