Terminal
This commit is contained in:
@@ -0,0 +1,574 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { useAuth, isOrgAdmin } from "@/hooks/use-auth";
|
||||||
|
import { Button } 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Check, Loader2, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/vacation")({
|
||||||
|
head: () => ({ meta: [{ title: "Vacation — School Portal" }] }),
|
||||||
|
component: VacationPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const weeks = (n: number | null | undefined) => (n ?? 0).toFixed(2).replace(/\.00$/, "");
|
||||||
|
|
||||||
|
function VacationPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
// Reading is deliberately not gated here — vacation_requests and
|
||||||
|
// vacation_balances are both readable by anyone who can access the student,
|
||||||
|
// so a teacher sees their own class and RLS does the filtering. Only the
|
||||||
|
// actions below are restricted, matching can_manage_student().
|
||||||
|
const canManage = isOrgAdmin(roles) || roles.includes("campus_admin");
|
||||||
|
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [campusFilter, setCampusFilter] = useState("all");
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [newStudent, setNewStudent] = useState("");
|
||||||
|
const [newStart, setNewStart] = useState("");
|
||||||
|
const [newEnd, setNewEnd] = useState("");
|
||||||
|
const [newWeeks, setNewWeeks] = useState("1");
|
||||||
|
const [newReason, setNewReason] = useState("");
|
||||||
|
const [overrideFor, setOverrideFor] = useState<{
|
||||||
|
studentId: string;
|
||||||
|
name: string;
|
||||||
|
current: number | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [overrideWeeks, setOverrideWeeks] = useState("");
|
||||||
|
const [overrideReason, setOverrideReason] = useState("");
|
||||||
|
|
||||||
|
const { data: campuses } = useQuery({
|
||||||
|
queryKey: ["campuses"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await supabase.from("campuses").select("id, name").order("name")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balances } = useQuery({
|
||||||
|
queryKey: ["vacation-balances", year],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("v_vacation_status")
|
||||||
|
.select(
|
||||||
|
"student_id, student_name, primary_campus_id, campus_name, policy_year, weeks_allotted, weeks_used, weeks_remaining, is_overridden, pending_requests",
|
||||||
|
)
|
||||||
|
.eq("policy_year", year)
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// vacation_requests is readable under can_access_student(), the same predicate
|
||||||
|
// that guards public.students, so the nested name select is safe here — unlike
|
||||||
|
// the billing views, where a billing admin sees invoices but no student rows.
|
||||||
|
const { data: requests } = useQuery({
|
||||||
|
queryKey: ["vacation-requests", year],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("vacation_requests")
|
||||||
|
.select(
|
||||||
|
"id, student_id, policy_year, start_date, end_date, weeks_charged, status, tuition_due, reason, approved_at, students(first_name, last_name, primary_campus_id)",
|
||||||
|
)
|
||||||
|
.eq("policy_year", year)
|
||||||
|
.order("start_date")
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["vacation-student-picker"],
|
||||||
|
enabled: canManage && newOpen,
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await supabase
|
||||||
|
.from("students")
|
||||||
|
.select("id, first_name, last_name, primary_campus_id")
|
||||||
|
.eq("enrollment_status", "enrolled")
|
||||||
|
.order("last_name")
|
||||||
|
).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inCampus = (campusId: string | null | undefined) =>
|
||||||
|
campusFilter === "all" || campusId === campusFilter;
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => (balances ?? []).filter((b) => inCampus(b.primary_campus_id)),
|
||||||
|
[balances, campusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending = useMemo(
|
||||||
|
() =>
|
||||||
|
(requests ?? []).filter(
|
||||||
|
(r) => r.status === "pending" && inCampus(r.students?.primary_campus_id),
|
||||||
|
),
|
||||||
|
[requests, campusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decided = useMemo(
|
||||||
|
() =>
|
||||||
|
(requests ?? []).filter(
|
||||||
|
(r) => r.status !== "pending" && inCampus(r.students?.primary_campus_id),
|
||||||
|
),
|
||||||
|
[requests, campusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const approvedWeeks = decided
|
||||||
|
.filter((r) => r.status === "approved")
|
||||||
|
.reduce((s, r) => s + (r.weeks_charged ?? 0), 0);
|
||||||
|
return {
|
||||||
|
pending: pending.length,
|
||||||
|
approvedWeeks,
|
||||||
|
overrides: rows.filter((b) => b.is_overridden).length,
|
||||||
|
entitled: rows.filter((b) => (b.weeks_allotted ?? 0) > 0).length,
|
||||||
|
};
|
||||||
|
}, [pending, decided, rows]);
|
||||||
|
|
||||||
|
const decide = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: string; status: "approved" | "denied" }) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vacation_requests")
|
||||||
|
.update({
|
||||||
|
status,
|
||||||
|
approved_by: user?.id ?? null,
|
||||||
|
approved_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", id);
|
||||||
|
// The entitlement guard raises here for a student with no vacation
|
||||||
|
// allowance. Its message names the student and explains the override
|
||||||
|
// route, so it is shown verbatim rather than replaced with a generic one.
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
},
|
||||||
|
onSuccess: (_d, v) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["vacation-requests", year] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["vacation-balances", year] });
|
||||||
|
toast.success(v.status === "approved" ? "Vacation approved" : "Request denied");
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message, { duration: 10000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const w = Number(newWeeks);
|
||||||
|
if (!newStudent) throw new Error("Choose a student");
|
||||||
|
if (!newStart || !newEnd) throw new Error("Enter both dates");
|
||||||
|
if (!Number.isFinite(w) || w <= 0) throw new Error("Weeks must be greater than zero");
|
||||||
|
// The vacation year resets on the policy's reset day, not on 1 January, so
|
||||||
|
// a request in January can belong to the previous policy year. Ask the
|
||||||
|
// database the same question its own insert trigger would rather than
|
||||||
|
// assuming the year currently selected in the filter.
|
||||||
|
const { data: policyYear, error: yErr } = await supabase.rpc("vacation_year_for", {
|
||||||
|
_student: newStudent,
|
||||||
|
_on: newStart,
|
||||||
|
});
|
||||||
|
if (yErr) throw new Error(yErr.message);
|
||||||
|
|
||||||
|
const { error } = await supabase.from("vacation_requests").insert({
|
||||||
|
student_id: newStudent,
|
||||||
|
policy_year: policyYear as number,
|
||||||
|
start_date: newStart,
|
||||||
|
end_date: newEnd,
|
||||||
|
weeks_charged: w,
|
||||||
|
reason: newReason || null,
|
||||||
|
requested_by: user?.id ?? null,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return policyYear as number;
|
||||||
|
},
|
||||||
|
onSuccess: (policyYear) => {
|
||||||
|
setNewOpen(false);
|
||||||
|
setNewStudent("");
|
||||||
|
setNewStart("");
|
||||||
|
setNewEnd("");
|
||||||
|
setNewWeeks("1");
|
||||||
|
setNewReason("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["vacation-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["vacation-balances"] });
|
||||||
|
if (policyYear !== year) {
|
||||||
|
// Say so rather than letting it vanish from a filter set to another year.
|
||||||
|
toast.success(`Request created under policy year ${policyYear}`);
|
||||||
|
setYear(policyYear);
|
||||||
|
} else {
|
||||||
|
toast.success("Request created");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveOverride = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const raw = overrideWeeks.trim();
|
||||||
|
const w = raw === "" ? null : Number(raw);
|
||||||
|
if (w !== null && (!Number.isFinite(w) || w < 0))
|
||||||
|
throw new Error("Weeks must be zero or more");
|
||||||
|
if (w !== null && !overrideReason.trim())
|
||||||
|
throw new Error("Give a reason — an override is an exception on the record");
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("vacation_balances")
|
||||||
|
.update({
|
||||||
|
override_weeks: w,
|
||||||
|
override_reason: w === null ? null : overrideReason.trim(),
|
||||||
|
overridden_by: w === null ? null : (user?.id ?? null),
|
||||||
|
})
|
||||||
|
.eq("student_id", overrideFor!.studentId)
|
||||||
|
.eq("policy_year", year);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
|
// weeks_allotted only picks the override up when the balance is recomputed;
|
||||||
|
// without this the row still shows the policy figure and the entitlement
|
||||||
|
// guard keeps refusing.
|
||||||
|
const { error: rErr } = await supabase.rpc("recalc_vacation_balance", {
|
||||||
|
_student: overrideFor!.studentId,
|
||||||
|
_year: year,
|
||||||
|
});
|
||||||
|
if (rErr) throw new Error(rErr.message);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setOverrideFor(null);
|
||||||
|
setOverrideWeeks("");
|
||||||
|
setOverrideReason("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["vacation-balances", year] });
|
||||||
|
toast.success("Allowance updated");
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const years = [year - 1, year, year + 1];
|
||||||
|
|
||||||
|
const tiles = [
|
||||||
|
{ label: "Awaiting decision", value: String(totals.pending), warn: totals.pending > 0 },
|
||||||
|
{ label: "Weeks approved", value: weeks(totals.approvedWeeks) },
|
||||||
|
{ label: "Students entitled", value: String(totals.entitled) },
|
||||||
|
{ label: "Overrides in force", value: String(totals.overrides) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 max-w-6xl">
|
||||||
|
<div className="flex flex-wrap items-end gap-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Vacation</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Vacation is allotted to full calendar year, full-time students. Anyone else needs an
|
||||||
|
explicit override before a request can be approved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
{canManage && (
|
||||||
|
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> New request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Select value={campusFilter} onValueChange={setCampusFilter}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All campuses</SelectItem>
|
||||||
|
{(campuses ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={String(year)} onValueChange={(v) => setYear(Number(v))}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{years.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
|
{tiles.map((t) => (
|
||||||
|
<div key={t.label} className="bg-card border rounded-lg p-4">
|
||||||
|
<div className="text-xs text-muted-foreground uppercase tracking-wide">{t.label}</div>
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-semibold tabular-nums mt-1 ${t.warn ? "text-amber-600" : ""}`}
|
||||||
|
>
|
||||||
|
{t.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-4 mb-6">
|
||||||
|
<div className="font-medium text-sm mb-3">Awaiting decision</div>
|
||||||
|
<div className="border rounded divide-y text-sm">
|
||||||
|
{pending.map((r) => {
|
||||||
|
const name = r.students ? `${r.students.first_name} ${r.students.last_name}` : "—";
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium">{name}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk
|
||||||
|
{r.tuition_due === false && " · tuition waived"}
|
||||||
|
</span>
|
||||||
|
{r.reason && (
|
||||||
|
<span className="block text-muted-foreground text-xs truncate">{r.reason}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{canManage && (
|
||||||
|
<span className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={decide.isPending}
|
||||||
|
onClick={() => decide.mutate({ id: r.id, status: "approved" })}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" /> Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={decide.isPending}
|
||||||
|
onClick={() => decide.mutate({ id: r.id, status: "denied" })}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pending.length === 0 && (
|
||||||
|
<div className="p-3 text-muted-foreground">Nothing awaiting a decision.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg overflow-x-auto mb-6">
|
||||||
|
<table className="w-full text-sm min-w-[44rem]">
|
||||||
|
<thead className="text-muted-foreground border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-normal p-2.5">Student</th>
|
||||||
|
<th className="text-left font-normal">Campus</th>
|
||||||
|
<th className="text-right font-normal">Allotted</th>
|
||||||
|
<th className="text-right font-normal">Used</th>
|
||||||
|
<th className="text-right font-normal">Remaining</th>
|
||||||
|
<th className="text-right font-normal">Pending</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((b) => {
|
||||||
|
const studentId = b.student_id;
|
||||||
|
const remaining = b.weeks_remaining ?? 0;
|
||||||
|
const entitled = (b.weeks_allotted ?? 0) > 0;
|
||||||
|
return (
|
||||||
|
<tr key={studentId} className="border-b last:border-0">
|
||||||
|
<td className="p-2.5 font-medium">
|
||||||
|
{b.student_name ?? "—"}
|
||||||
|
{b.is_overridden && (
|
||||||
|
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-muted font-normal">
|
||||||
|
override
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground">{b.campus_name ?? "—"}</td>
|
||||||
|
<td className="text-right tabular-nums">
|
||||||
|
{entitled ? (
|
||||||
|
weeks(b.weeks_allotted)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">none</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums">{weeks(b.weeks_used)}</td>
|
||||||
|
<td
|
||||||
|
className={`text-right tabular-nums font-medium ${
|
||||||
|
remaining < 0 ? "text-rose-600" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{weeks(remaining)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right tabular-nums text-muted-foreground">
|
||||||
|
{b.pending_requests ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="pr-2 text-right">
|
||||||
|
{canManage && studentId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title="Set an explicit allowance for this student"
|
||||||
|
onClick={() => {
|
||||||
|
setOverrideFor({
|
||||||
|
studentId,
|
||||||
|
name: b.student_name ?? "this student",
|
||||||
|
current: b.is_overridden ? (b.weeks_allotted ?? null) : null,
|
||||||
|
});
|
||||||
|
setOverrideWeeks(b.is_overridden ? weeks(b.weeks_allotted) : "");
|
||||||
|
setOverrideReason("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="p-4 text-center text-muted-foreground">
|
||||||
|
No vacation balances for {year}.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decided.length > 0 && (
|
||||||
|
<div className="bg-card border rounded-lg p-4">
|
||||||
|
<div className="font-medium text-sm mb-3">Decided</div>
|
||||||
|
<div className="border rounded divide-y text-sm">
|
||||||
|
{decided.map((r) => (
|
||||||
|
<div key={r.id} className="flex items-center justify-between gap-3 p-2.5">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-medium">
|
||||||
|
{r.students ? `${r.students.first_name} ${r.students.last_name}` : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· {r.start_date} → {r.end_date} · {weeks(r.weeks_charged)} wk
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${
|
||||||
|
r.status === "approved" ? "bg-emerald-100 text-emerald-800" : "bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={newOpen} onOpenChange={setNewOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New vacation request</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Created as pending. Approving it is what draws down the allowance.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Student</Label>
|
||||||
|
<Select value={newStudent} onValueChange={setNewStudent}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a student" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(students ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.last_name}, {s.first_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">From</Label>
|
||||||
|
<Input type="date" value={newStart} onChange={(e) => setNewStart(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">To</Label>
|
||||||
|
<Input type="date" value={newEnd} onChange={(e) => setNewEnd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Weeks charged</Label>
|
||||||
|
<Input value={newWeeks} onChange={(e) => setNewWeeks(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Reason (optional)</Label>
|
||||||
|
<Input value={newReason} onChange={(e) => setNewReason(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setNewOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => create.mutate()} disabled={create.isPending}>
|
||||||
|
{create.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||||
|
Create request
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!overrideFor} onOpenChange={(o) => !o && setOverrideFor(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Override allowance</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{overrideFor &&
|
||||||
|
`Sets ${overrideFor.name}'s allowance for ${year} regardless of policy. Leave the field empty to remove the override and fall back to the policy figure.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Weeks allowed</Label>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="e.g. 2 — or empty to clear"
|
||||||
|
value={overrideWeeks}
|
||||||
|
onChange={(e) => setOverrideWeeks(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Reason</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Recorded against your name"
|
||||||
|
value={overrideReason}
|
||||||
|
onChange={(e) => setOverrideReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOverrideFor(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => saveOverride.mutate()} disabled={saveOverride.isPending}>
|
||||||
|
{saveOverride.isPending && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user