Terminal
This commit is contained in:
@@ -1,574 +0,0 @@
|
|||||||
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