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:
parent
2613c9d337
commit
9f246ee31c
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
21
backend/payslip_access.py
Normal 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
189
backend/routes/payslips.py
Normal 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}
|
||||
@ -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"):
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = [
|
||||
|
||||
313
frontend/src/pages/PaySlips.jsx
Normal file
313
frontend/src/pages/PaySlips.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user