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:
Syazwan 2026-06-01 20:23:00 +08:00
parent 19a28bb4c2
commit 1828f54745
6 changed files with 1148 additions and 2 deletions

4
.gitignore vendored
View File

@ -14,8 +14,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/

24
frontend/src/lib/api.js Normal file
View 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
View 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];

View 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;
}

View 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);
});
});

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}