225 lines
10 KiB
TypeScript
225 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { VacationStatus, VacationType } from "@capakraken/shared";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { VacationModal } from "./VacationModal.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { BalanceCard } from "./BalanceCard.js";
|
|
import { VacationCalendar } from "./VacationCalendar.js";
|
|
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
|
|
import { getHolidayBasis, getHolidayBreakdown, getRequestedDays, type VacationExplainabilityEntry } from "./vacationExplainability.js";
|
|
|
|
type VacationListItem = VacationExplainabilityEntry & {
|
|
id: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
status: string;
|
|
type: string;
|
|
note?: string | null;
|
|
rejectionReason?: string | null;
|
|
};
|
|
|
|
export function MyVacationsClient() {
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
const utils = trpc.useUtils();
|
|
|
|
// Find resource linked to current user
|
|
const { data: myResource } = trpc.resource.getMyResource.useQuery(undefined, {
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
const resourceId = myResource?.id;
|
|
|
|
const vacationListQuery = trpc.vacation.list.useQuery as unknown as (
|
|
input: { limit: number; resourceId?: string | undefined },
|
|
options: { enabled: boolean; staleTime: number },
|
|
) => {
|
|
data: VacationListItem[] | undefined;
|
|
isLoading: boolean;
|
|
refetch: () => Promise<unknown>;
|
|
};
|
|
|
|
const { data: vacations, isLoading, refetch } = vacationListQuery(
|
|
{ limit: 200, ...(resourceId ? { resourceId } : {}) },
|
|
{ enabled: !!resourceId, staleTime: 15_000 },
|
|
);
|
|
|
|
const cancelMutation = trpc.vacation.cancel.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.vacation.list.invalidate();
|
|
await utils.entitlement.getBalance.invalidate();
|
|
},
|
|
});
|
|
|
|
const vacationList: VacationListItem[] = vacations ?? [];
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">My Vacations</h1>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage your personal vacation requests</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(true)}
|
|
disabled={!resourceId}
|
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
|
>
|
|
+ Request Vacation
|
|
</button>
|
|
</div>
|
|
|
|
{!resourceId && (
|
|
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4 text-sm text-amber-700">
|
|
Your account is not linked to a resource. Please contact an administrator.
|
|
</div>
|
|
)}
|
|
|
|
{/* Balance card */}
|
|
{resourceId && (
|
|
<BalanceCard resourceId={resourceId} />
|
|
)}
|
|
|
|
{/* Calendar */}
|
|
{resourceId && vacationList.length > 0 && (
|
|
<VacationCalendar vacations={vacationList} />
|
|
)}
|
|
|
|
{/* Vacation list */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
|
) : vacationList.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-gray-400">No vacation requests yet.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
|
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
|
Type <InfoTooltip content="ANNUAL = paid annual leave · SICK = sick leave · PUBLIC_HOLIDAY = public holiday · OTHER = other leave types." />
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">Start</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">End</th>
|
|
<th className="text-right px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
|
<span className="inline-flex items-center justify-end gap-0.5">
|
|
Days <InfoTooltip content="Calendar days from start to end date (inclusive). Shows 0.5 for half-day requests (½ indicator on start date)." />
|
|
</span>
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
|
Status <InfoTooltip content="PENDING = awaiting manager approval · APPROVED = confirmed leave · REJECTED = declined by manager · CANCELLED = withdrawn by employee." />
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider">
|
|
Note <InfoTooltip content="Your note on the request, or the manager's rejection reason if declined." />
|
|
</th>
|
|
<th className="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
|
|
{vacationList.map((v) => {
|
|
const start = new Date(v.startDate);
|
|
const end = new Date(v.endDate);
|
|
const requestedDays = getRequestedDays(v);
|
|
const deductedDays = typeof v.deductedDays === "number" ? v.deductedDays : null;
|
|
const holidayBasis = getHolidayBasis(v);
|
|
const holidayBreakdown = getHolidayBreakdown(v);
|
|
const status = v.status as string;
|
|
const type = v.type as string;
|
|
const affectsBalance = type === VacationType.ANNUAL || type === VacationType.OTHER;
|
|
|
|
return (
|
|
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${VACATION_TYPE_BADGE[type] ?? "bg-gray-100 text-gray-600"}`}>
|
|
{TYPE_LABELS[type] ?? type}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
|
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
|
|
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">
|
|
<div className="space-y-1">
|
|
<div>{requestedDays}</div>
|
|
{affectsBalance && deductedDays !== null && (
|
|
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
|
Deducted: {deductedDays}
|
|
</div>
|
|
)}
|
|
{affectsBalance && deductedDays === 0 && holidayBreakdown.length > 0 && (
|
|
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
|
Fully covered by holidays
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
|
|
{status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[280px]">
|
|
{v.rejectionReason ? (
|
|
<span className="text-red-500">{v.rejectionReason}</span>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div>{v.note ?? "—"}</div>
|
|
{affectsBalance && holidayBasis.length > 0 && (
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
|
Holiday basis: {holidayBasis.join(" / ")}
|
|
</div>
|
|
)}
|
|
{affectsBalance && holidayBreakdown.length > 0 && (
|
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
|
Excluded holidays: {holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!v.rejectionReason && affectsBalance && deductedDays !== null && deductedDays !== requestedDays && holidayBreakdown.length === 0 && (
|
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
|
Local holiday-adjusted deduction snapshot applied.
|
|
</div>
|
|
)}
|
|
{!v.rejectionReason && !affectsBalance && (
|
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
|
This leave type does not reduce annual vacation entitlement.
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => cancelMutation.mutate({ id: v.id })}
|
|
disabled={cancelMutation.isPending}
|
|
className="text-xs text-gray-400 hover:text-red-600 underline disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{showModal && resourceId && (
|
|
<VacationModal
|
|
resourceId={resourceId}
|
|
onClose={() => setShowModal(false)}
|
|
onSuccess={() => {
|
|
setShowModal(false);
|
|
void refetch();
|
|
void utils.entitlement.getBalance.invalidate();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|