Track frontend src/lib and fix root-only lib gitignore.
The global lib/ rule excluded api.js and auth.jsx from the repo, breaking deploys and API URL config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
19a28bb4c2
commit
1828f54745
4
.gitignore
vendored
4
.gitignore
vendored
@ -14,8 +14,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
24
frontend/src/lib/api.js
Normal file
24
frontend/src/lib/api.js
Normal file
@ -0,0 +1,24 @@
|
||||
import axios from "axios";
|
||||
|
||||
/** Claim API — local default is 8002 (not legacy ProcureFlow on 8001). */
|
||||
const BASE = (process.env.REACT_APP_BACKEND_URL || "http://127.0.0.1:8002").replace(/\/$/, "");
|
||||
|
||||
export const apiBaseUrl = BASE;
|
||||
|
||||
export const googleLoginUrl = () => `${BASE}/api/auth/google/login`;
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${BASE}/api`,
|
||||
withCredentials: true, // httpOnly auth cookies are the sole token transport
|
||||
});
|
||||
|
||||
export const fileUrl = (path) =>
|
||||
path?.startsWith("http") ? path : `${BASE}${path}`;
|
||||
|
||||
export function apiErrorText(err) {
|
||||
const d = err?.response?.data?.detail;
|
||||
if (!d) return err?.message || "Something went wrong";
|
||||
if (typeof d === "string") return d;
|
||||
if (Array.isArray(d)) return d.map((x) => x?.msg || JSON.stringify(x)).join(" ");
|
||||
return JSON.stringify(d);
|
||||
}
|
||||
90
frontend/src/lib/auth.jsx
Normal file
90
frontend/src/lib/auth.jsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null); // null = loading, false = not logged in, object = logged in
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// Retry transient failures (Render free-tier cold-starts can take 30-60 s,
|
||||
// backend OOM-restarts during heavy OCR also produce a 10-30 s blackout).
|
||||
// Only treat HTTP 401 as "definitely logged out". Network errors / 5xx
|
||||
// are retried so the user isn't bounced to /login mid-restart.
|
||||
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
||||
const attempts = [0, 1500, 4000, 8000]; // total ~13.5 s before giving up
|
||||
for (let i = 0; i < attempts.length; i++) {
|
||||
if (attempts[i]) await sleep(attempts[i]);
|
||||
try {
|
||||
const r = await api.get("/auth/me", { timeout: 8000 });
|
||||
setUser(r.data);
|
||||
setReady(true);
|
||||
return;
|
||||
} catch (err) {
|
||||
const status = err?.response?.status;
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (status === 401 || (status === 403 && detail !== "PENDING_APPROVAL")) {
|
||||
setUser(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
if (status === 403 && detail === "PENDING_APPROVAL") {
|
||||
// Account exists but awaiting approval — treat as not logged in
|
||||
setUser(false);
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
// network error / 5xx — retry
|
||||
if (i === attempts.length - 1) {
|
||||
// Out of retries: keep current value (null = still loading) so we don't
|
||||
// wrongly redirect to /login. Mark ready so the UI can render an error.
|
||||
setUser((prev) => (prev === null ? false : prev));
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const r = await api.post("/auth/login", { email, password });
|
||||
// Access token is delivered via httpOnly cookie; we only keep the user payload in memory.
|
||||
setUser(r.data.user);
|
||||
return r.data.user;
|
||||
};
|
||||
|
||||
const register = async (email, password, name) => {
|
||||
const r = await api.post("/auth/register", { email, password, name });
|
||||
if (r.data.pending) {
|
||||
// Account created but awaiting admin approval — do not set session
|
||||
return { pending: true, email: r.data.email };
|
||||
}
|
||||
setUser(r.data.user);
|
||||
return r.data.user;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
} catch (err) {
|
||||
// Non-fatal: still clear local state even if the server couldn't be reached.
|
||||
console.warn("Logout request failed:", err?.message || err);
|
||||
}
|
||||
setUser(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, ready, login, register, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
export const ROLE_RANK = { viewer: 0, user: 1, manager: 2, admin: 3 };
|
||||
export const can = (user, min) =>
|
||||
user && ROLE_RANK[user.role] !== undefined && ROLE_RANK[user.role] >= ROLE_RANK[min];
|
||||
815
frontend/src/lib/claimDates.js
Normal file
815
frontend/src/lib/claimDates.js
Normal file
@ -0,0 +1,815 @@
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
|
||||
|
||||
|
||||
/** Claim Date / date_sign — strict DD/MM/YYYY in forms and preview footer. */
|
||||
|
||||
export const CLAIM_DATE_FORMAT = "dd/MM/yyyy";
|
||||
|
||||
export const CLAIM_DATE_PLACEHOLDER = "dd/mm/yyyy";
|
||||
|
||||
export const CLAIM_DATE_STORAGE_FORMAT = "yyyy-MM-dd";
|
||||
|
||||
|
||||
|
||||
/** Line item DATE — uppercase full month name only (e.g. NOVEMBER). */
|
||||
|
||||
export const LINE_ITEM_DATE_FORMAT = "MMMM";
|
||||
|
||||
export const LINE_ITEM_DATE_PLACEHOLDER = "NOVEMBER";
|
||||
|
||||
|
||||
|
||||
/** OT line item DATE — day + title-case month + year (e.g. 18 May 2026). */
|
||||
|
||||
export const OT_LINE_ITEM_DATE_FORMAT = "d MMMM yyyy";
|
||||
|
||||
export const OT_LINE_ITEM_DATE_PLACEHOLDER = "18 May 2026";
|
||||
|
||||
|
||||
|
||||
export const LINE_ITEM_STYLE_MONTH_ONLY = "month_only";
|
||||
|
||||
// Legacy constants kept for backward compatibility in function signatures
|
||||
const LINE_ITEM_STYLE_DAY_MONTH_YEAR = "day_month_year";
|
||||
|
||||
/** MEAL travel DATE — passthrough text (e.g. 16-19/9/25). */
|
||||
|
||||
export const LINE_ITEM_STYLE_MEAL_TRAVEL = "meal_travel_date";
|
||||
|
||||
export const LINE_ITEM_STYLE_SLASH_DATE = "slash_date";
|
||||
|
||||
export const MEAL_LINE_ITEM_DATE_PLACEHOLDER = "16-19/9/25";
|
||||
|
||||
export const MEAL_TIME_PLACEHOLDER = "9:00 AM";
|
||||
|
||||
|
||||
|
||||
export const CLAIM_DATE_FIELD_KEYS = new Set(["date_sign"]);
|
||||
|
||||
export const MEAL_TIME_FIELD_KEYS = new Set(["time_depart", "time_arrive"]);
|
||||
|
||||
|
||||
|
||||
const MONTH_NAME_TO_NUM = {
|
||||
|
||||
january: 1,
|
||||
|
||||
february: 2,
|
||||
|
||||
march: 3,
|
||||
|
||||
april: 4,
|
||||
|
||||
may: 5,
|
||||
|
||||
june: 6,
|
||||
|
||||
july: 7,
|
||||
|
||||
august: 8,
|
||||
|
||||
september: 9,
|
||||
|
||||
october: 10,
|
||||
|
||||
november: 11,
|
||||
|
||||
december: 12,
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const FLEX_PARSE_FORMATS = [
|
||||
|
||||
CLAIM_DATE_FORMAT,
|
||||
|
||||
"d/M/yyyy",
|
||||
|
||||
"dd/M/yyyy",
|
||||
|
||||
"d/MM/yyyy",
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const OT_LINE_ITEM_PARSE_FORMATS = [
|
||||
|
||||
OT_LINE_ITEM_DATE_FORMAT,
|
||||
|
||||
"d/M/yy",
|
||||
|
||||
"dd/M/yy",
|
||||
|
||||
"d MMMM yyyy",
|
||||
|
||||
"d MMM yyyy",
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
export function lineItemDateStyleForClaimType(claimType) {
|
||||
|
||||
const ct = String(claimType || "").toUpperCase();
|
||||
|
||||
if (ct === "MILEAGE" || ct === "MEAL" || ct === "OT") return LINE_ITEM_STYLE_SLASH_DATE;
|
||||
|
||||
return LINE_ITEM_STYLE_MONTH_ONLY;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isClaimDateField(fieldKey) {
|
||||
|
||||
return CLAIM_DATE_FIELD_KEYS.has(fieldKey);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isLineItemDateField(fieldKey) {
|
||||
|
||||
return fieldKey === "date";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function yearFromPeriod(period) {
|
||||
|
||||
if (!period) return null;
|
||||
|
||||
const m = String(period).trim().match(/^(\d{4})-(\d{2})/);
|
||||
|
||||
return m ? Number(m[1]) : null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function parseMonthNameOnly(text, period) {
|
||||
|
||||
const key = String(text ?? "").trim().toLowerCase();
|
||||
|
||||
const month = MONTH_NAME_TO_NUM[key];
|
||||
|
||||
if (!month) return null;
|
||||
|
||||
const year = yearFromPeriod(period) ?? new Date().getFullYear();
|
||||
|
||||
return new Date(year, month - 1, 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function parseNumericMonthOnly(text, period) {
|
||||
|
||||
const s = String(text ?? "").trim();
|
||||
|
||||
if (!/^\d{1,2}$/.test(s)) return null;
|
||||
|
||||
const month = Number(s);
|
||||
|
||||
if (month < 1 || month > 12) return null;
|
||||
|
||||
const year = yearFromPeriod(period) ?? new Date().getFullYear();
|
||||
|
||||
return new Date(year, month - 1, 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function isMonthNameOnly(text) {
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
|
||||
MONTH_NAME_TO_NUM,
|
||||
|
||||
String(text ?? "").trim().toLowerCase()
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function isPlausibleClaimYear(d) {
|
||||
|
||||
const y = d.getFullYear();
|
||||
|
||||
return y >= 1900 && y <= 2100;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function isMealTravelRangeText(text) {
|
||||
|
||||
const s = String(text ?? "").trim();
|
||||
|
||||
const m = s.match(/^(\d{1,2})-(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
|
||||
|
||||
if (!m) return false;
|
||||
|
||||
const start = Number(m[1]);
|
||||
|
||||
const end = Number(m[2]);
|
||||
|
||||
const month = Number(m[3]);
|
||||
|
||||
// Basic validation: start ≤ end, both ≤ 31, month ≤ 12
|
||||
|
||||
return start >= 1 && end >= 1 && start <= end && start <= 31 && end <= 31 && month >= 1 && month <= 12;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** yyyy-MM-dd bounds for native date input.
|
||||
*
|
||||
* Historically scoped to the claim period month (min/max of YYYY-MM),
|
||||
* which prevented backdated/future-dated entries from being selected.
|
||||
*
|
||||
* Returning empty bounds lets the browser date picker accept any valid date.
|
||||
*/
|
||||
export function periodDateInputBounds(period) {
|
||||
void period; // keep signature stable for callers
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Value for `<input type="date">` from storage or display text. */
|
||||
|
||||
export function toNativeDateInputValue(stored, period = null, style = LINE_ITEM_STYLE_SLASH_DATE) {
|
||||
|
||||
if (!stored) return "";
|
||||
|
||||
const iso = String(stored).trim().match(/^(\d{4}-\d{2}-\d{2})/);
|
||||
|
||||
if (iso) return iso[1];
|
||||
|
||||
const fromText = lineItemDateToStorage(stored, period, style);
|
||||
|
||||
return fromText || "";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Infer day/month from slash-separated parts (DD/MM/YYYY preferred for Malaysian locale).
|
||||
|
||||
* Heuristic: if one part > 12, it's the day. If both ≤ 12, assume DD/MM (not MM/DD).
|
||||
|
||||
* For ambiguous dates (03/05), assumes day=3, month=5 per local convention. */
|
||||
|
||||
function slashPartsToDayMonth(a, b) {
|
||||
|
||||
if (a > 12) return { day: a, month: b };
|
||||
|
||||
if (b > 12) return { day: b, month: a };
|
||||
|
||||
if (a > b) return { day: b, month: a };
|
||||
|
||||
return { day: a, month: b };
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Parse user/API date strings into a Date (DD/MM first; legacy month names use period). */
|
||||
|
||||
export function claimDateParse(input, period = null) {
|
||||
|
||||
const s = String(input ?? "").trim();
|
||||
|
||||
if (!s) return null;
|
||||
|
||||
|
||||
|
||||
const monthOnly = parseMonthNameOnly(s, period);
|
||||
|
||||
if (monthOnly && isValid(monthOnly)) return monthOnly;
|
||||
|
||||
|
||||
|
||||
const numericMonth = parseNumericMonthOnly(s, period);
|
||||
|
||||
if (numericMonth && isValid(numericMonth)) return numericMonth;
|
||||
|
||||
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
|
||||
const d = parse(s.slice(0, 10), CLAIM_DATE_STORAGE_FORMAT, new Date());
|
||||
|
||||
if (isValid(d)) return d;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
for (const fmt of FLEX_PARSE_FORMATS) {
|
||||
|
||||
const d = parse(s, fmt, new Date());
|
||||
|
||||
if (isValid(d) && isPlausibleClaimYear(d)) return d;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const shortYy = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2})$/);
|
||||
|
||||
if (shortYy) {
|
||||
|
||||
const a = Number(shortYy[1]);
|
||||
|
||||
const b = Number(shortYy[2]);
|
||||
|
||||
const y = 2000 + Number(shortYy[3]);
|
||||
|
||||
const { day, month } = slashPartsToDayMonth(a, b);
|
||||
|
||||
const d = new Date(y, month - 1, day);
|
||||
|
||||
if (isValid(d) && isPlausibleClaimYear(d)) return d;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
for (const fmt of OT_LINE_ITEM_PARSE_FORMATS) {
|
||||
|
||||
const d = parse(s, fmt, new Date());
|
||||
|
||||
if (isValid(d) && isPlausibleClaimYear(d)) return d;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const m = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/);
|
||||
|
||||
if (m) {
|
||||
|
||||
const a = Number(m[1]);
|
||||
|
||||
const b = Number(m[2]);
|
||||
|
||||
const yStr = m[3];
|
||||
|
||||
let y = Number(yStr);
|
||||
|
||||
if (yStr.length === 2) {
|
||||
|
||||
y += 2000;
|
||||
|
||||
} else if (yStr.length === 4 && y < 100) {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
const { day, month } = slashPartsToDayMonth(a, b);
|
||||
|
||||
const d = new Date(y, month - 1, day);
|
||||
|
||||
if (isValid(d) && isPlausibleClaimYear(d)) return d;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Display in form inputs (dd/MM/yyyy). Never echo legacy month-name-only text. */
|
||||
|
||||
export function claimDateToDisplay(stored, period = null) {
|
||||
|
||||
if (!stored) return "";
|
||||
|
||||
const d = claimDateParse(stored, period);
|
||||
|
||||
return d ? format(d, CLAIM_DATE_FORMAT) : "";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Store as yyyy-MM-dd for API. */
|
||||
|
||||
export function claimDateToStorage(input, period = null) {
|
||||
|
||||
const s = String(input ?? "").trim();
|
||||
|
||||
if (!s) return "";
|
||||
|
||||
const d = claimDateParse(s, period);
|
||||
|
||||
return d ? format(d, CLAIM_DATE_STORAGE_FORMAT) : "";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function claimDateNormalizeOnBlur(input, period = null) {
|
||||
|
||||
return claimDateToStorage(input, period);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function lineItemDatePlaceholder(style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
if (style === LINE_ITEM_STYLE_DAY_MONTH_YEAR) return OT_LINE_ITEM_DATE_PLACEHOLDER;
|
||||
|
||||
if (style === LINE_ITEM_STYLE_MEAL_TRAVEL) return MEAL_LINE_ITEM_DATE_PLACEHOLDER;
|
||||
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE) return CLAIM_DATE_PLACEHOLDER;
|
||||
|
||||
return LINE_ITEM_DATE_PLACEHOLDER;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function lineItemDateFormat(style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE) return CLAIM_DATE_FORMAT;
|
||||
|
||||
return style === LINE_ITEM_STYLE_DAY_MONTH_YEAR
|
||||
|
||||
? OT_LINE_ITEM_DATE_FORMAT
|
||||
|
||||
: LINE_ITEM_DATE_FORMAT;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Display line item DATE (month-only or OT day-month-year). */
|
||||
|
||||
export function lineItemDateToDisplay(stored, period = null, style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
if (!stored) return "";
|
||||
|
||||
if (style === LINE_ITEM_STYLE_MEAL_TRAVEL) {
|
||||
|
||||
const d = claimDateParse(stored, period);
|
||||
|
||||
if (d) return String(stored).trim();
|
||||
|
||||
return String(stored).trim();
|
||||
|
||||
}
|
||||
|
||||
const d = claimDateParse(stored, period);
|
||||
|
||||
if (d) {
|
||||
|
||||
const fmt = lineItemDateFormat(style);
|
||||
|
||||
const out = format(d, fmt);
|
||||
|
||||
return style === LINE_ITEM_STYLE_MONTH_ONLY ? out.toUpperCase() : out;
|
||||
|
||||
}
|
||||
|
||||
if (style === LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
return String(stored).trim();
|
||||
|
||||
}
|
||||
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE) {
|
||||
|
||||
return String(stored).trim();
|
||||
|
||||
}
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Store line item date as yyyy-MM-dd for API (first of month when only month is given). */
|
||||
|
||||
export function lineItemDateToStorage(input, period = null, style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
const s = String(input ?? "").trim();
|
||||
|
||||
if (!s) return "";
|
||||
|
||||
if (style === LINE_ITEM_STYLE_MEAL_TRAVEL) {
|
||||
|
||||
const d = claimDateParse(s, period);
|
||||
|
||||
return d ? format(d, CLAIM_DATE_STORAGE_FORMAT) : s;
|
||||
|
||||
}
|
||||
|
||||
if (isMealTravelRangeText(s)) {
|
||||
|
||||
return s;
|
||||
|
||||
}
|
||||
|
||||
if (
|
||||
(style === LINE_ITEM_STYLE_DAY_MONTH_YEAR ||
|
||||
style === LINE_ITEM_STYLE_SLASH_DATE) &&
|
||||
isMonthNameOnly(s)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const d = claimDateParse(s, period);
|
||||
|
||||
return d ? format(d, CLAIM_DATE_STORAGE_FORMAT) : "";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function lineItemDateNormalizeOnBlur(input, period = null, style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
return lineItemDateToStorage(input, period, style);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Normalize line item date from API (clears unparseable legacy month-only unless period applies). */
|
||||
|
||||
export function sanitizeLineItemDate(stored, period = null, style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE && isMonthNameOnly(stored)) {
|
||||
|
||||
const d = claimDateParse(stored, period);
|
||||
|
||||
return d ? format(d, CLAIM_DATE_STORAGE_FORMAT) : "";
|
||||
|
||||
}
|
||||
|
||||
return lineItemDateToStorage(stored, period, style);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Display meal departure/arrival — Excel serial (0.375) → 9:00 AM. */
|
||||
|
||||
export function formatMealTimeForDisplay(val) {
|
||||
|
||||
if (val === null || val === undefined || val === "") return "";
|
||||
|
||||
const s = String(val).trim();
|
||||
|
||||
if (!s) return "";
|
||||
|
||||
const m = s.match(/^(\d{1,2}):(\d{2})/);
|
||||
|
||||
if (m) {
|
||||
|
||||
const h = Number(m[1]);
|
||||
|
||||
const min = m[2];
|
||||
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
|
||||
const h12 = h % 12 || 12;
|
||||
|
||||
return `${h12}:${min} ${ampm}`;
|
||||
|
||||
}
|
||||
|
||||
const f = typeof val === "number" ? val : Number(s);
|
||||
|
||||
if (!Number.isNaN(f) && f >= 0 && f < 1) {
|
||||
|
||||
const seconds = Math.round(f * 86400) % 86400;
|
||||
|
||||
const h = Math.floor(seconds / 3600);
|
||||
|
||||
const min = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
|
||||
const h12 = h % 12 || 12;
|
||||
|
||||
return `${h12}:${String(min).padStart(2, "0")} ${ampm}`;
|
||||
|
||||
}
|
||||
|
||||
return s;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Store meal times as H:MM (24h) for API / Excel fill. */
|
||||
|
||||
export function normalizeMealTimeStorage(val) {
|
||||
|
||||
const s = String(val ?? "").trim();
|
||||
|
||||
if (!s) return "";
|
||||
|
||||
const m12 = s.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
||||
|
||||
if (m12) {
|
||||
|
||||
let h = Number(m12[1]);
|
||||
|
||||
const min = m12[2];
|
||||
|
||||
if (m12[3].toUpperCase() === "PM" && h !== 12) h += 12;
|
||||
|
||||
if (m12[3].toUpperCase() === "AM" && h === 12) h = 0;
|
||||
|
||||
return `${h}:${min}`;
|
||||
|
||||
}
|
||||
|
||||
const m = s.match(/^(\d{1,2}):(\d{2})/);
|
||||
|
||||
if (m) return `${Number(m[1])}:${m[2]}`;
|
||||
|
||||
const f = Number(s);
|
||||
|
||||
if (!Number.isNaN(f) && f >= 0 && f < 1) {
|
||||
|
||||
const seconds = Math.round(f * 86400) % 86400;
|
||||
|
||||
const h = Math.floor(seconds / 3600);
|
||||
|
||||
const min = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
return `${h}:${String(min).padStart(2, "0")}`;
|
||||
|
||||
}
|
||||
|
||||
return s;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function sanitizeClaimItem(row, claimType, period = null) {
|
||||
|
||||
if (!row) return row;
|
||||
|
||||
const ct = String(claimType || "").toUpperCase();
|
||||
|
||||
const out = { ...row };
|
||||
|
||||
if (ct === "MEAL") {
|
||||
|
||||
if (out.date) {
|
||||
|
||||
out.date = sanitizeLineItemDate(
|
||||
|
||||
out.date,
|
||||
|
||||
period,
|
||||
|
||||
LINE_ITEM_STYLE_MEAL_TRAVEL
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if (out.time_depart !== undefined && out.time_depart !== "") {
|
||||
|
||||
out.time_depart = normalizeMealTimeStorage(out.time_depart);
|
||||
|
||||
}
|
||||
|
||||
if (out.time_arrive !== undefined && out.time_arrive !== "") {
|
||||
|
||||
out.time_arrive = normalizeMealTimeStorage(out.time_arrive);
|
||||
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
}
|
||||
|
||||
if (out.date) {
|
||||
|
||||
out.date = sanitizeLineItemDate(
|
||||
|
||||
out.date,
|
||||
|
||||
period,
|
||||
|
||||
lineItemDateStyleForClaimType(ct)
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function validateClaimDate(input, period = null) {
|
||||
|
||||
const s = String(input ?? "").trim();
|
||||
|
||||
if (!s) return null;
|
||||
|
||||
if (claimDateParse(s, period)) return null;
|
||||
|
||||
return "Claim Date must be DD/MM/YYYY (e.g. 18/05/2026).";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function validateLineItemDate(input, period = null, style = LINE_ITEM_STYLE_MONTH_ONLY) {
|
||||
|
||||
const s = String(input ?? "").trim();
|
||||
|
||||
if (!s) return null;
|
||||
|
||||
if (style === LINE_ITEM_STYLE_MEAL_TRAVEL) return null;
|
||||
|
||||
if (isMealTravelRangeText(s)) return null;
|
||||
|
||||
if (
|
||||
(style === LINE_ITEM_STYLE_DAY_MONTH_YEAR ||
|
||||
style === LINE_ITEM_STYLE_SLASH_DATE) &&
|
||||
isMonthNameOnly(s)
|
||||
) {
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE) {
|
||||
return "Line item Date must be DD/MM/YYYY (e.g. 18/06/2026).";
|
||||
}
|
||||
return "Line item Date must include day, month and year (e.g. 18 May 2026).";
|
||||
}
|
||||
|
||||
if (claimDateParse(s, period)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (style === LINE_ITEM_STYLE_SLASH_DATE) {
|
||||
return "Line item Date must be DD/MM/YYYY (e.g. 18/06/2026).";
|
||||
}
|
||||
|
||||
if (style === LINE_ITEM_STYLE_DAY_MONTH_YEAR) {
|
||||
return "Line item Date must be DD Month YYYY (e.g. 18 May 2026).";
|
||||
}
|
||||
|
||||
return "Line item Date must be a valid month name or date.";
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Collect validation errors before preview/print (claim date + line items when filled). */
|
||||
|
||||
export function validateClaimFormDates(claimType, header, items, period = null) {
|
||||
|
||||
const errors = [];
|
||||
|
||||
const lineStyle = lineItemDateStyleForClaimType(claimType);
|
||||
|
||||
|
||||
|
||||
const claimErr = validateClaimDate(header?.date_sign, period);
|
||||
|
||||
if (claimErr) errors.push(claimErr);
|
||||
|
||||
|
||||
|
||||
(items || []).forEach((it, idx) => {
|
||||
|
||||
if (!it?.date) return;
|
||||
|
||||
const err = validateLineItemDate(it.date, period, lineStyle);
|
||||
|
||||
if (err) errors.push(`Row ${idx + 1}: ${err}`);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return errors;
|
||||
|
||||
}
|
||||
|
||||
|
||||
211
frontend/src/lib/claimDates.test.js
Normal file
211
frontend/src/lib/claimDates.test.js
Normal file
@ -0,0 +1,211 @@
|
||||
import {
|
||||
|
||||
claimDateToDisplay,
|
||||
|
||||
claimDateToStorage,
|
||||
|
||||
isClaimDateField,
|
||||
|
||||
isLineItemDateField,
|
||||
|
||||
lineItemDateStyleForClaimType,
|
||||
|
||||
lineItemDateToDisplay,
|
||||
|
||||
lineItemDateToStorage,
|
||||
|
||||
LINE_ITEM_STYLE_DAY_MONTH_YEAR,
|
||||
|
||||
LINE_ITEM_STYLE_MONTH_ONLY,
|
||||
|
||||
LINE_ITEM_STYLE_SLASH_DATE,
|
||||
|
||||
sanitizeLineItemDate,
|
||||
|
||||
validateClaimFormDates,
|
||||
|
||||
validateLineItemDate,
|
||||
|
||||
} from "./claimDates";
|
||||
|
||||
|
||||
|
||||
describe("claimDates field types", () => {
|
||||
|
||||
it("distinguishes CLAIM DATE vs line item DATE", () => {
|
||||
|
||||
expect(isClaimDateField("date_sign")).toBe(true);
|
||||
|
||||
expect(isClaimDateField("date")).toBe(false);
|
||||
|
||||
expect(isLineItemDateField("date")).toBe(true);
|
||||
|
||||
expect(isLineItemDateField("date_sign")).toBe(false);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("formats claim date as DD/MM/YYYY", () => {
|
||||
|
||||
expect(claimDateToDisplay("2026-12-01")).toBe("01/12/2026");
|
||||
|
||||
expect(claimDateToStorage("01/12/2026")).toBe("2026-12-01");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("formats line item date as uppercase month name", () => {
|
||||
|
||||
expect(lineItemDateToDisplay("2026-11-01")).toBe("NOVEMBER");
|
||||
|
||||
expect(lineItemDateToDisplay("2024-12-04")).toBe("DECEMBER");
|
||||
|
||||
expect(lineItemDateToStorage("NOVEMBER", "2024-11")).toBe("2024-11-01");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("formats OT and meal line item dates as DD/MM/YYYY", () => {
|
||||
|
||||
expect(lineItemDateStyleForClaimType("OT")).toBe(LINE_ITEM_STYLE_SLASH_DATE);
|
||||
|
||||
expect(lineItemDateStyleForClaimType("MEAL")).toBe(LINE_ITEM_STYLE_SLASH_DATE);
|
||||
|
||||
expect(
|
||||
|
||||
lineItemDateToDisplay("2026-05-18", null, LINE_ITEM_STYLE_SLASH_DATE)
|
||||
|
||||
).toBe("18/05/2026");
|
||||
|
||||
expect(lineItemDateToStorage("18/05/2026", null, LINE_ITEM_STYLE_SLASH_DATE)).toBe(
|
||||
|
||||
"2026-05-18"
|
||||
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("converts legacy NOVEMBER using claim period", () => {
|
||||
|
||||
expect(lineItemDateToDisplay("NOVEMBER", "2024-11")).toBe("NOVEMBER");
|
||||
|
||||
expect(sanitizeLineItemDate("NOVEMBER", "2024-11")).toBe("2024-11-01");
|
||||
|
||||
expect(claimDateToDisplay("NOVEMBER", "2024-11")).toBe("01/11/2024");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("rejects month-only for OT line items", () => {
|
||||
|
||||
expect(
|
||||
|
||||
validateLineItemDate("NOVEMBER", "2026-05", LINE_ITEM_STYLE_SLASH_DATE)
|
||||
|
||||
).toMatch(/DD\/MM\/YYYY/i);
|
||||
|
||||
expect(lineItemDateToStorage("NOVEMBER", "2026-05", LINE_ITEM_STYLE_SLASH_DATE)).toBe("");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("formats mileage line item date as DD/MM/YYYY", () => {
|
||||
|
||||
expect(lineItemDateStyleForClaimType("MILEAGE")).toBe(LINE_ITEM_STYLE_SLASH_DATE);
|
||||
|
||||
expect(
|
||||
|
||||
lineItemDateToDisplay("2026-06-15", null, LINE_ITEM_STYLE_SLASH_DATE)
|
||||
|
||||
).toBe("15/06/2026");
|
||||
|
||||
expect(
|
||||
|
||||
lineItemDateToStorage("15/06/2026", null, LINE_ITEM_STYLE_SLASH_DATE)
|
||||
|
||||
).toBe("2026-06-15");
|
||||
|
||||
expect(sanitizeLineItemDate("JUNE", "2026-06", LINE_ITEM_STYLE_SLASH_DATE)).toBe(
|
||||
|
||||
"2026-06-01"
|
||||
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("accepts numeric month for expenses line items", () => {
|
||||
|
||||
expect(lineItemDateToStorage("5", "2026-05", LINE_ITEM_STYLE_MONTH_ONLY)).toBe(
|
||||
|
||||
"2026-05-01"
|
||||
|
||||
);
|
||||
|
||||
expect(lineItemDateToStorage("05", "2026-05", LINE_ITEM_STYLE_MONTH_ONLY)).toBe(
|
||||
|
||||
"2026-05-01"
|
||||
|
||||
);
|
||||
|
||||
expect(lineItemDateToDisplay("5", "2026-05", LINE_ITEM_STYLE_MONTH_ONLY)).toBe("MAY");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("rejects implausible year from partial dd/mm/yy typing", () => {
|
||||
|
||||
expect(claimDateToDisplay("31/12/20", "2026-05")).toBe("31/12/2020");
|
||||
|
||||
expect(claimDateToDisplay("31/12/0020", "2026-05")).toBe("");
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("validateClaimFormDates for OT", () => {
|
||||
|
||||
const ok = validateClaimFormDates(
|
||||
|
||||
"OT",
|
||||
|
||||
{ date_sign: "2026-05-18" },
|
||||
|
||||
[{ date: "2026-05-18" }],
|
||||
|
||||
"2026-05"
|
||||
|
||||
);
|
||||
|
||||
expect(ok).toEqual([]);
|
||||
|
||||
|
||||
|
||||
const bad = validateClaimFormDates(
|
||||
|
||||
"OT",
|
||||
|
||||
{ date_sign: "bad" },
|
||||
|
||||
[{ date: "NOVEMBER" }],
|
||||
|
||||
"2026-05"
|
||||
|
||||
);
|
||||
|
||||
expect(bad.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
6
frontend/src/lib/utils.js
Normal file
6
frontend/src/lib/utils.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user