Add Pay Slips page with PDF upload and preview.

Per-user payslip library API, sidebar route, and clearer errors when the API is not deployed yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Syazwan 2026-06-01 20:18:30 +08:00
parent 2613c9d337
commit 9f246ee31c
7 changed files with 545 additions and 2 deletions

1
.gitignore vendored
View File

@ -213,6 +213,7 @@ backend/.env
frontend/.env
backend/uploads/
backend/claim_files/
backend/payslip_files/
# Test / scratch artifacts
backend/tmp/

21
backend/payslip_access.py Normal file
View File

@ -0,0 +1,21 @@
"""Pay slip ownership and query scoping — users only see their own files."""
from __future__ import annotations
from typing import Any, Dict
from fastapi import HTTPException
from core import db
def payslip_scope_query(user: dict) -> Dict[str, Any]:
uid = user["id"]
return {"owner_id": uid}
async def get_payslip_for_user(payslip_id: str, user: dict) -> dict:
q: Dict[str, Any] = {"$and": [{"id": payslip_id}, payslip_scope_query(user)]}
rec = await db.payslips.find_one(q, {"_id": 0})
if not rec:
raise HTTPException(status_code=404, detail="Pay slip not found")
return rec

189
backend/routes/payslips.py Normal file
View File

@ -0,0 +1,189 @@
"""User-scoped pay slip PDF library (/api/payslips)."""
from __future__ import annotations
import re
import uuid
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from claim_access import owner_fields
from core import ROOT_DIR, db
from deps import get_current_user
from payslip_access import get_payslip_for_user, payslip_scope_query
from services import storage_service
from services.audit_service import log_from_user
router = APIRouter(prefix="/payslips", tags=["payslips"])
PAYSLIP_DIR = ROOT_DIR / "payslip_files"
PAYSLIP_DIR.mkdir(exist_ok=True)
_MAX_BYTES = 15 * 1024 * 1024
_DATE_RE = re.compile(r"(20\d{2})[-_.]?(0[1-9]|1[0-2])(?:[-_.]?(0[1-9]|[12]\d|3[01]))?")
def _parse_payslip_date(raw: Optional[str]) -> Optional[date]:
if not raw or not str(raw).strip():
return None
text = str(raw).strip()[:10]
try:
return date.fromisoformat(text)
except ValueError:
return None
def _infer_payslip_date(filename: str, provided: Optional[str]) -> date:
parsed = _parse_payslip_date(provided)
if parsed:
return parsed
name = filename or ""
match = _DATE_RE.search(name)
if match:
y, mo = int(match.group(1)), int(match.group(2))
day = int(match.group(3)) if match.group(3) else 1
try:
return date(y, mo, day)
except ValueError:
try:
return date(y, mo, 1)
except ValueError:
pass
return datetime.now(timezone.utc).date()
def _public_item(rec: dict) -> dict:
return {
"id": rec["id"],
"filename": rec.get("filename"),
"payslip_date": rec.get("payslip_date"),
"size": rec.get("size"),
"uploaded_at": rec.get("uploaded_at"),
}
async def _resolve_file_path(rec: dict) -> Path:
"""Return a local path to the PDF, fetching from R2 when needed."""
local = rec.get("path")
if local:
path = Path(local)
if path.is_file():
return path
if rec.get("storage") == "r2":
tmp = PAYSLIP_DIR / f"_cache_{rec['id']}.pdf"
fetched = await storage_service.download_pdf(rec["id"], tmp)
if fetched and fetched.is_file():
return fetched
raise HTTPException(status_code=404, detail="File missing on disk")
@router.get("")
@router.get("/")
async def list_payslips(user: dict = Depends(get_current_user)):
q = payslip_scope_query(user)
cursor = db.payslips.find(q, {"_id": 0, "path": 0}).sort(
[("payslip_date", -1), ("uploaded_at", -1)]
)
items: List[dict] = []
async for doc in cursor:
items.append(_public_item(doc))
return {"items": items, "total": len(items)}
@router.post("")
@router.post("/")
async def upload_payslip(
file: UploadFile = File(...),
payslip_date: Optional[str] = Form(None),
actor: dict = Depends(get_current_user),
):
filename = file.filename or "payslip.pdf"
if not filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are allowed.")
content_type = (file.content_type or "").lower()
if content_type and content_type != "application/pdf":
raise HTTPException(status_code=400, detail="Only PDF files are allowed.")
content = await file.read()
if len(content) > _MAX_BYTES:
raise HTTPException(status_code=400, detail="File too large (max 15 MB)")
if not content.startswith(b"%PDF"):
raise HTTPException(status_code=400, detail="Invalid PDF file.")
slip_date = _infer_payslip_date(filename, payslip_date)
payslip_id = str(uuid.uuid4())
dest = PAYSLIP_DIR / f"{payslip_id}.pdf"
async with aiofiles.open(dest, "wb") as fh:
await fh.write(content)
storage = "local"
if storage_service.R2_CONFIGURED:
await storage_service.put_pdf_from_path(payslip_id, dest)
storage = "r2"
now = datetime.now(timezone.utc).isoformat()
rec: Dict[str, Any] = {
"id": payslip_id,
"filename": filename,
"payslip_date": slip_date.isoformat(),
"content_type": "application/pdf",
"size": len(content),
"uploaded_at": now,
"path": str(dest),
"storage": storage,
**owner_fields(actor),
}
await db.payslips.insert_one({**rec, "_id": payslip_id})
await log_from_user(
db,
actor,
action="PAYSLIP_UPLOAD",
target_type="payslip",
target_id=payslip_id,
meta={"filename": filename, "payslip_date": rec["payslip_date"]},
)
return _public_item(rec)
@router.get("/{payslip_id}/file")
async def serve_payslip_file(payslip_id: str, user: dict = Depends(get_current_user)):
rec = await get_payslip_for_user(payslip_id, user)
if rec.get("storage") == "r2":
url = await storage_service.presigned_get_url(
payslip_id, filename=rec.get("filename"), expires=900
)
if url:
from fastapi.responses import RedirectResponse
return RedirectResponse(url, status_code=302)
path = await _resolve_file_path(rec)
return FileResponse(
str(path),
media_type="application/pdf",
filename=rec.get("filename") or "payslip.pdf",
headers={"Content-Disposition": f'inline; filename="{rec.get("filename", "payslip.pdf")}"'},
)
@router.delete("/{payslip_id}")
async def delete_payslip(payslip_id: str, actor: dict = Depends(get_current_user)):
rec = await get_payslip_for_user(payslip_id, actor)
local_path = rec.get("path")
if local_path:
Path(local_path).unlink(missing_ok=True)
if rec.get("storage") == "r2":
await storage_service.delete_pdf(payslip_id)
await db.payslips.delete_one({"id": payslip_id})
await log_from_user(
db,
actor,
action="PAYSLIP_DELETE",
target_type="payslip",
target_id=payslip_id,
meta={"filename": rec.get("filename")},
)
return {"deleted": payslip_id}

View File

@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from core import client, db, logger
from routes import admin, auth, claims, dashboard
from routes import admin, auth, claims, dashboard, payslips
from routes.admin import refresh_company_settings
from services import storage_service
@ -17,6 +17,7 @@ api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router)
api_router.include_router(claims.router)
api_router.include_router(payslips.router)
api_router.include_router(dashboard.router)
api_router.include_router(admin.router)
@ -53,6 +54,9 @@ async def on_startup():
await db.claims.create_index("created_at")
await db.login_attempts.create_index("identifier", unique=True)
await db.audit_logs.create_index("created_at")
await db.payslips.create_index("id", unique=True)
await db.payslips.create_index("owner_id")
await db.payslips.create_index([("owner_id", 1), ("payslip_date", -1)])
async for doc in db.claims.find({"owner_id": {"$exists": False}}, {"id": 1, "created_by": 1}):
if doc.get("created_by"):

View File

@ -15,6 +15,7 @@ import AdminCompanySettings from "./pages/AdminCompanySettings";
import AdminClaims from "./pages/AdminClaims";
import AdminClaimForm from "./pages/AdminClaimForm";
import AdminClaimImport from "./pages/AdminClaimImport";
import PaySlips from "./pages/PaySlips";
function Shell({ children }) {
const location = useLocation();
@ -53,6 +54,7 @@ export default function App() {
<Route path="/" element={<Protected minRole="user"><Dashboard /></Protected>} />
<Route path="/claims" element={<Protected minRole="user"><AdminClaims /></Protected>} />
<Route path="/claims/import" element={<Protected minRole="user"><AdminClaimImport /></Protected>} />
<Route path="/payslips" element={<Protected minRole="user"><PaySlips /></Protected>} />
<Route path="/claims/new" element={<Protected minRole="user"><AdminClaimForm /></Protected>} />
<Route path="/claims/:id/edit" element={<Protected minRole="user"><AdminClaimForm /></Protected>} />
@ -67,6 +69,18 @@ export default function App() {
<Route path="/create" element={<Navigate to="/claims/new" replace />} />
<Route path="/reports" element={<Navigate to="/" replace />} />
<Route path="/admin/templates" element={<Navigate to="/" replace />} />
<Route
path="*"
element={
<div className="p-10">
<div className="pf-overline mb-2">404</div>
<h1 className="font-display text-[28px] font-bold">Page not found</h1>
<p className="text-[13px] text-[#52525B] mt-2">
<a href="/" className="text-[#0F52BA] underline">Back to dashboard</a>
</p>
</div>
}
/>
</Routes>
</Shell>
</AuthProvider>

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
GridFour, Receipt, Users, ListBullets, SignOut, LockKey, Buildings, CurrencyDollar, UploadSimple,
GridFour, Receipt, Users, ListBullets, SignOut, LockKey, Buildings, CurrencyDollar, UploadSimple, FilePdf,
} from "@phosphor-icons/react";
import { useAuth, can } from "../lib/auth";
import { api } from "../lib/api";
@ -10,6 +10,7 @@ const LINKS = [
{ to: "/", label: "Dashboard", icon: GridFour, end: true, minRole: "user" },
{ to: "/claims", label: "My Claims", icon: CurrencyDollar, minRole: "user" },
{ to: "/claims/import", label: "Import Excel", icon: UploadSimple, minRole: "user" },
{ to: "/payslips", label: "Pay Slips", icon: FilePdf, minRole: "user" },
];
const ADMIN_LINKS = [

View File

@ -0,0 +1,313 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
FilePdf,
UploadSimple,
Trash,
CalendarBlank,
CircleNotch,
X,
} from "@phosphor-icons/react";
import { api, apiErrorText } from "../lib/api";
function formatPayslipDate(iso) {
if (!iso) return "—";
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(y, (m || 1) - 1, d || 1);
return dt.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
}
function formatUploadedAt(iso) {
if (!iso) return "";
try {
return new Date(iso).toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "";
}
}
export default function PaySlips() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const [uploading, setUploading] = useState(false);
const [selected, setSelected] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [payslipDate, setPayslipDate] = useState("");
const fileRef = useRef(null);
const load = useCallback(async () => {
setLoading(true);
setErr("");
try {
const r = await api.get("/payslips");
const list = r.data.items || [];
setItems(list);
setSelected((prev) => {
if (!prev) return list[0] || null;
return list.find((x) => x.id === prev.id) || list[0] || null;
});
} catch (e) {
const status = e?.response?.status;
if (status === 404) {
setErr(
"Pay slips API is not available. Restart the backend (port 8002) or redeploy the API on Render after pulling the latest code."
);
} else {
setErr(apiErrorText(e));
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (!selected) {
setPreviewUrl(null);
return undefined;
}
let revoked = false;
let objectUrl = null;
setPreviewLoading(true);
api
.get(`/payslips/${selected.id}/file`, { responseType: "blob" })
.then((res) => {
if (revoked) return;
objectUrl = URL.createObjectURL(
new Blob([res.data], { type: "application/pdf" })
);
setPreviewUrl(objectUrl);
})
.catch((e) => {
if (!revoked) setErr(apiErrorText(e));
})
.finally(() => {
if (!revoked) setPreviewLoading(false);
});
return () => {
revoked = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
setPreviewUrl(null);
};
}, [selected?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const onUpload = async (fileList) => {
const file = fileList?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith(".pdf")) {
setErr("Only PDF pay slips can be uploaded.");
return;
}
setUploading(true);
setErr("");
try {
const form = new FormData();
form.append("file", file);
if (payslipDate) form.append("payslip_date", payslipDate);
const r = await api.post("/payslips", form, {
headers: { "Content-Type": "multipart/form-data" },
});
await load();
setSelected(r.data);
setPayslipDate("");
if (fileRef.current) fileRef.current.value = "";
} catch (e) {
setErr(apiErrorText(e));
} finally {
setUploading(false);
}
};
const remove = async (id) => {
if (!window.confirm("Delete this pay slip?")) return;
try {
await api.delete(`/payslips/${id}`);
if (selected?.id === id) setSelected(null);
await load();
} catch (e) {
setErr(apiErrorText(e));
}
};
return (
<div className="flex flex-col min-h-0 flex-1 pf-fade-up" data-testid="payslips-page">
<div className="p-4 sm:p-6 lg:p-10 border-b border-[#E5E7EB] bg-[#FAFAFA]">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="pf-overline mb-2">My space</div>
<h1 className="font-display text-[32px] font-bold tracking-tight leading-none">Pay Slips</h1>
<p className="text-[13px] text-[#52525B] mt-2 max-w-xl">
Store PDF pay slips only. Newest pay period first click a file to view it here.
</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="block">
<span className="pf-overline block mb-1">Pay period date</span>
<input
type="date"
className="pf-input min-w-[160px] text-[15px]"
value={payslipDate}
onChange={(e) => setPayslipDate(e.target.value)}
data-testid="payslip-date-input"
/>
</label>
<input
ref={fileRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
data-testid="payslip-file-input"
onChange={(e) => onUpload(e.target.files)}
/>
<button
type="button"
className="pf-btn pf-btn-primary"
disabled={uploading}
onClick={() => fileRef.current?.click()}
data-testid="payslip-upload-btn"
>
{uploading ? (
<CircleNotch size={14} className="animate-spin" />
) : (
<UploadSimple size={14} />
)}
{uploading ? "Uploading…" : "Upload PDF"}
</button>
</div>
</div>
{err && (
<div className="mt-4 text-[13px] text-[#B91C1C] bg-[#FEF2F2] border border-[#FECACA] px-3 py-2 rounded-sm">
{err}
</div>
)}
</div>
<div className="flex flex-1 min-h-0 flex-col lg:flex-row">
<section
className="lg:w-[340px] xl:w-[380px] shrink-0 border-b lg:border-b-0 lg:border-r border-[#E5E7EB] flex flex-col min-h-[240px] lg:min-h-0"
aria-label="Pay slip list"
>
<div className="px-4 py-3 border-b border-[#E5E7EB] flex items-center justify-between bg-white">
<span className="text-[12px] font-medium text-[#52525B]">
{loading ? "Loading…" : `${items.length} file${items.length === 1 ? "" : "s"}`}
</span>
<span className="text-[10px] uppercase tracking-[0.12em] text-[#A1A1AA]">Newest first</span>
</div>
<div className="flex-1 overflow-y-auto bg-white">
{loading && (
<div className="p-8 text-center text-[#71717A] text-[13px] pf-pulse">Loading pay slips</div>
)}
{!loading && items.length === 0 && (
<div className="p-8 text-center">
<FilePdf size={40} className="mx-auto mb-3 text-[#D4D4D8]" />
<p className="text-[13px] text-[#71717A]">No pay slips yet. Upload a PDF to get started.</p>
</div>
)}
{!loading &&
items.map((row) => {
const active = selected?.id === row.id;
return (
<div
key={row.id}
className={
"group flex items-stretch border-b border-[#F4F4F5] transition-colors " +
(active ? "bg-[#F4F4F5]" : "hover:bg-[#FAFAFA]")
}
>
<button
type="button"
className="flex-1 text-left px-4 py-3 min-w-0"
onClick={() => setSelected(row)}
data-testid={`payslip-row-${row.id}`}
>
<div className="flex items-start gap-2">
<FilePdf
size={18}
weight={active ? "fill" : "regular"}
className="shrink-0 mt-0.5 text-[#0F52BA]"
/>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium truncate" title={row.filename}>
{row.filename}
</div>
<div className="flex items-center gap-1.5 mt-1 text-[11px] text-[#71717A]">
<CalendarBlank size={12} />
{formatPayslipDate(row.payslip_date)}
</div>
<div className="text-[10px] text-[#A1A1AA] mt-0.5">
Uploaded {formatUploadedAt(row.uploaded_at)}
</div>
</div>
</div>
</button>
<button
type="button"
className="px-3 opacity-0 group-hover:opacity-100 focus:opacity-100 text-[#71717A] hover:text-[#B91C1C] transition-opacity"
title="Delete"
onClick={() => remove(row.id)}
data-testid={`payslip-delete-${row.id}`}
>
<Trash size={16} />
</button>
</div>
);
})}
</div>
</section>
<section className="flex-1 flex flex-col min-h-[320px] lg:min-h-0 bg-[#F4F4F5]" aria-label="PDF preview">
{!selected && !loading && (
<div className="flex-1 flex items-center justify-center p-10 text-[#71717A] text-[13px]">
Select a pay slip to preview
</div>
)}
{selected && (
<>
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[#E5E7EB]">
<div className="min-w-0">
<div className="text-[13px] font-medium truncate">{selected.filename}</div>
<div className="text-[11px] text-[#71717A]">
Pay period: {formatPayslipDate(selected.payslip_date)}
</div>
</div>
<button
type="button"
className="pf-btn pf-btn-ghost shrink-0"
onClick={() => setSelected(null)}
title="Close preview"
>
<X size={14} />
</button>
</div>
<div className="flex-1 relative min-h-0 p-3 sm:p-4">
{previewLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#F4F4F5] z-10">
<CircleNotch size={28} className="animate-spin text-[#71717A]" />
</div>
)}
{previewUrl && !previewLoading && (
<iframe
title={selected.filename}
src={previewUrl}
className="w-full h-full min-h-[280px] bg-white border border-[#E5E7EB] shadow-sm"
data-testid="payslip-preview-frame"
/>
)}
</div>
</>
)}
</section>
</div>
</div>
);
}