- Ajouter les états sortField et sortOrder au composant PageContrats - Modifier le hook useContrats pour passer sort et order à l'API - Adapter l'endpoint /api/contrats pour supporter les paramètres de tri dynamiques - Rendre les headers 'Début' et 'Fin' cliquables avec indicateurs visuels (▲/▼) - Tri par défaut: date de fin décroissante (contrats les plus proches d'expirer en premier)
387 lines
No EOL
18 KiB
TypeScript
387 lines
No EOL
18 KiB
TypeScript
// app/api/contrats/route.ts
|
|
import { NextResponse } from "next/server";
|
|
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
|
import { cookies, headers } from "next/headers";
|
|
import { resolveActiveOrg } from "@/lib/resolveActiveOrg";
|
|
import { createClient } from '@supabase/supabase-js';
|
|
import { detectDemoModeFromHeaders } from "@/lib/demo-detector";
|
|
import { DEMO_CONTRACTS, DEMO_ORGANIZATION } from "@/lib/demo-data";
|
|
|
|
// Force dynamic rendering and disable revalidation cache for this proxy
|
|
export const dynamic = 'force-dynamic';
|
|
export const revalidate = 0;
|
|
export const runtime = 'nodejs';
|
|
|
|
const UPSTREAM_BASE =
|
|
process.env.STRUCTURE_API_BASE_URL ||
|
|
"https://0rryyjj6vh.execute-api.eu-west-3.amazonaws.com/default";
|
|
const PATH_PREFIX = process.env.STRUCTURE_API_PATH_PREFIX ?? "";
|
|
|
|
function buildUpstreamUrl(req: Request) {
|
|
const url = new URL(req.url);
|
|
const upstream = new URL(UPSTREAM_BASE + `${PATH_PREFIX}/contrats`);
|
|
// Rejoue tous les query params vers l'amont
|
|
url.searchParams.forEach((v, k) => upstream.searchParams.set(k, v));
|
|
return upstream;
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
// 🎭 Vérification du mode démo en premier
|
|
const h = headers();
|
|
const isDemoMode = detectDemoModeFromHeaders(h);
|
|
|
|
if (isDemoMode) {
|
|
console.log("🎭 [API CONTRATS] Mode démo détecté - renvoi de données fictives");
|
|
|
|
const url = new URL(req.url);
|
|
const regime = url.searchParams.get("regime");
|
|
const status = url.searchParams.get("status");
|
|
const page = parseInt(url.searchParams.get("page") || "1");
|
|
const limit = parseInt(url.searchParams.get("limit") || "10");
|
|
|
|
// Filtrer les contrats selon les paramètres
|
|
let filteredContracts = DEMO_CONTRACTS;
|
|
|
|
if (regime === "CDDU") {
|
|
filteredContracts = filteredContracts.filter(c =>
|
|
c.regime === "CDDU_MONO" || c.regime === "CDDU_MULTI"
|
|
);
|
|
} else if (regime === "RG") {
|
|
filteredContracts = filteredContracts.filter(c => c.regime === "RG");
|
|
}
|
|
|
|
if (status === "en_cours") {
|
|
filteredContracts = filteredContracts.filter(c =>
|
|
c.etat === "en_cours" || c.etat === "signe"
|
|
);
|
|
} else if (status === "termines") {
|
|
filteredContracts = filteredContracts.filter(c =>
|
|
c.etat === "traitee"
|
|
);
|
|
}
|
|
|
|
// Pagination
|
|
const startIndex = (page - 1) * limit;
|
|
const endIndex = startIndex + limit;
|
|
const paginatedContracts = filteredContracts.slice(startIndex, endIndex);
|
|
|
|
return NextResponse.json({
|
|
items: paginatedContracts,
|
|
total: filteredContracts.length,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(filteredContracts.length / limit),
|
|
hasMore: endIndex < filteredContracts.length
|
|
});
|
|
}
|
|
|
|
try {
|
|
const url = new URL(req.url);
|
|
const regime = url.searchParams.get("regime");
|
|
// Si CDDU, RG, ou absence de regime (= tous), lire depuis Supabase (table cddu_contracts)
|
|
if (regime === "CDDU" || regime === "RG" || !regime) {
|
|
const sb = createRouteHandlerClient({ cookies });
|
|
// Use centralized resolver which normalizes header/cookie values like 'unknown'
|
|
// and implements staff null-org semantics (returns null for global staff access).
|
|
const orgId = await resolveActiveOrg(sb);
|
|
|
|
// If orgId is not found here it means either the user is a staff with no active org
|
|
// or the resolution failed. For staff we want to allow global access (orgId === null).
|
|
// For non-staff users, absence of orgId will result in an empty result set later.
|
|
// Pagination
|
|
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
|
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
|
|
const offset = (page - 1) * limit;
|
|
// Recherche
|
|
const q = url.searchParams.get("q")?.trim();
|
|
// Tri
|
|
const sort = url.searchParams.get("sort") || "date_fin";
|
|
const order = url.searchParams.get("order") || "desc";
|
|
// Support explicit org filter via query param `org_id` (sent by client UI when staff selects a structure).
|
|
// Resolution priority:
|
|
// 1) If `org_id` query param is present, allow it only for staff or if it matches resolved orgId for non-staff.
|
|
// 2) Else if resolved orgId is present, scope to it.
|
|
// 3) Else resolved orgId === null -> staff global access: use admin client to read all rows.
|
|
const requestedOrg = url.searchParams.get("org_id") || undefined;
|
|
let query: any;
|
|
|
|
// Helper: determine if this is a staff user. Note: resolveActiveOrg may return an org id
|
|
// when the UI set a header/cookie; however the current session user may still be a staff
|
|
// account. We therefore explicitly check the session to detect staff privileges so that
|
|
// staff users who provided an explicit org can still be handled via the admin client.
|
|
let detectedIsStaff = false;
|
|
try {
|
|
const { data: { session } } = await sb.auth.getSession();
|
|
if (session) {
|
|
try {
|
|
const { data: staffRow } = await sb.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle();
|
|
detectedIsStaff = !!(staffRow as any)?.is_staff;
|
|
} catch {
|
|
const userMeta = session.user?.user_metadata || {};
|
|
const appMeta = session.user?.app_metadata || {};
|
|
detectedIsStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
|
|
}
|
|
}
|
|
} catch {
|
|
detectedIsStaff = false;
|
|
}
|
|
|
|
// Consider the caller as staff if either resolveActiveOrg returned null (explicit staff global)
|
|
// or the session indicates staff privileges.
|
|
const isStaff = orgId === null || detectedIsStaff;
|
|
|
|
// Prepare admin client for staff reads (bypass RLS) if possible
|
|
let admin: any = null;
|
|
if (isStaff) {
|
|
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
console.error('Service role key not configured; cannot perform staff admin reads');
|
|
// we'll still try to use sb but results may be restricted
|
|
} else {
|
|
admin = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, {
|
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (requestedOrg) {
|
|
// If non-staff provided a requestedOrg, ensure it matches their resolved orgId
|
|
if (!isStaff && orgId && requestedOrg !== orgId) {
|
|
return NextResponse.json({ items: [], page, limit, hasMore: false });
|
|
}
|
|
// Staff should use admin client to bypass RLS when filtering by a specific org
|
|
if (isStaff && admin) {
|
|
query = admin.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", requestedOrg);
|
|
} else {
|
|
query = sb.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", requestedOrg);
|
|
}
|
|
} else if (orgId) {
|
|
if (isStaff && admin) {
|
|
query = admin.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", orgId);
|
|
} else {
|
|
query = sb.from("cddu_contracts").select("*, organizations!inner(name)").eq("org_id", orgId);
|
|
}
|
|
} else {
|
|
// orgId === null and no requestedOrg -> staff global read required
|
|
if (!isStaff) {
|
|
return NextResponse.json({ items: [], page, limit, hasMore: false });
|
|
}
|
|
if (!admin) {
|
|
console.error('Service role key not configured; cannot perform staff global read');
|
|
return NextResponse.json({ items: [], page, limit, hasMore: false });
|
|
}
|
|
query = admin.from("cddu_contracts").select("*, organizations!inner(name)");
|
|
}
|
|
// We'll fetch rows then filter in JS to avoid brittle SQL patterns (accents/variants)
|
|
if (q) {
|
|
query = query.or(`employee_name.ilike.%${q}%,contract_number.ilike.%${q}%,production_name.ilike.%${q}%`);
|
|
}
|
|
// On ne filtre plus par statut SQL, mais par date de fin côté Node.js
|
|
// TODO: Filtres période/année/mois si besoin
|
|
query = query.order("created_at", { ascending: false });
|
|
const { data, error } = await query;
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
// If caller asked for debug resolution, return org resolution info + raw row count
|
|
const debugResolve = url.searchParams.get("debug_resolve");
|
|
if (debugResolve === "1") {
|
|
const resolvedSource = req.headers.get("x-active-org-id") ? "header" : cookies().get("active_org_id") ? "cookie" : "session-db";
|
|
return NextResponse.json({ resolved: { orgId, resolvedSource }, rows: (data || []).length, sample: (data || []).slice(0, 3) });
|
|
}
|
|
// Filtrage JS selon la date de fin, année, période, puis tri du plus récent au plus ancien
|
|
const status = url.searchParams.get("status");
|
|
const today = new Date();
|
|
today.setHours(0,0,0,0); // UTC minuit
|
|
const year = url.searchParams.get("year") ? parseInt(url.searchParams.get("year")!, 10) : undefined;
|
|
// Prise en compte des paramètres month, quarter, semester, period
|
|
const month = url.searchParams.get("month") ? parseInt(url.searchParams.get("month")!, 10) : undefined;
|
|
const quarter = url.searchParams.get("quarter") ? parseInt(url.searchParams.get("quarter")!, 10) : undefined;
|
|
const semester = url.searchParams.get("semester") ? parseInt(url.searchParams.get("semester")!, 10) : undefined;
|
|
const period = url.searchParams.get("period") || "Y";
|
|
// Helper pour filtrer par période
|
|
function isInPeriod(date: Date) {
|
|
if (!year) return true;
|
|
if (date.getFullYear() !== year) return false;
|
|
if (typeof month === "number") {
|
|
return date.getMonth() + 1 === month;
|
|
} else if (typeof quarter === "number") {
|
|
// Trimestres : Q1=janv-mars, Q2=avr-juin, Q3=juil-sept, Q4=oct-déc
|
|
if (quarter === 1) return date.getMonth() >= 0 && date.getMonth() <= 2;
|
|
if (quarter === 2) return date.getMonth() >= 3 && date.getMonth() <= 5;
|
|
if (quarter === 3) return date.getMonth() >= 6 && date.getMonth() <= 8;
|
|
if (quarter === 4) return date.getMonth() >= 9 && date.getMonth() <= 11;
|
|
} else if (typeof semester === "number") {
|
|
// Semestres : S1=janv-juin, S2=juil-déc
|
|
if (semester === 1) return date.getMonth() >= 0 && date.getMonth() <= 5;
|
|
if (semester === 2) return date.getMonth() >= 6 && date.getMonth() <= 11;
|
|
} else if (period.startsWith("M")) {
|
|
const m = parseInt(period.slice(1), 10);
|
|
return date.getMonth() + 1 === m;
|
|
} else if (period.startsWith("Q")) {
|
|
const q = parseInt(period.slice(1), 10);
|
|
if (q === 1) return date.getMonth() >= 0 && date.getMonth() <= 2;
|
|
if (q === 2) return date.getMonth() >= 3 && date.getMonth() <= 5;
|
|
if (q === 3) return date.getMonth() >= 6 && date.getMonth() <= 8;
|
|
if (q === 4) return date.getMonth() >= 9 && date.getMonth() <= 11;
|
|
} else if (period.startsWith("S")) {
|
|
const s = parseInt(period.slice(1), 10);
|
|
if (s === 1) return date.getMonth() >= 0 && date.getMonth() <= 5;
|
|
if (s === 2) return date.getMonth() >= 6 && date.getMonth() <= 11;
|
|
}
|
|
// "Y" ou autre : toute l'année
|
|
return true;
|
|
}
|
|
let filtered = (data || []).filter((row: any) => {
|
|
const end = row.end_date || row.date_fin;
|
|
if (!end) return false;
|
|
const endDate = new Date(end);
|
|
if (isNaN(endDate.getTime())) return false;
|
|
endDate.setHours(0,0,0,0);
|
|
if (status === "en_cours") {
|
|
return endDate >= today;
|
|
} else if (status === "termines") {
|
|
if (endDate < today && isInPeriod(endDate)) return true;
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
// Filter by requested regime: if caller asked for RG, keep only rows where type_d_embauche indicates RG
|
|
// If caller asked for CDDU, exclude RG rows so they don't appear in CDDU tab
|
|
// If no regime specified, keep all rows (CDDU + RG)
|
|
const normalize = (s: any) => String(s || '').toLowerCase();
|
|
if (regime === 'RG') {
|
|
filtered = filtered.filter((row: any) => {
|
|
const td = normalize(row.type_d_embauche);
|
|
return td.includes('régime général') || td.includes('regime general') || td === 'rg';
|
|
});
|
|
} else if (regime === 'CDDU') {
|
|
filtered = filtered.filter((row: any) => {
|
|
const td = normalize(row.type_d_embauche);
|
|
return !(td.includes('régime général') || td.includes('regime general') || td === 'rg');
|
|
});
|
|
}
|
|
// Si regime === null/undefined, on garde tous les contrats (pas de filtrage)
|
|
// Tri basé sur les paramètres sort/order
|
|
filtered.sort((a: any, b: any) => {
|
|
let aValue: any;
|
|
let bValue: any;
|
|
|
|
// Déterminer le champ à trier
|
|
if (sort === 'date_debut' || sort === 'start_date') {
|
|
aValue = new Date(a.start_date || a.date_debut || 0);
|
|
bValue = new Date(b.start_date || b.date_debut || 0);
|
|
} else {
|
|
// Défaut: date_fin
|
|
aValue = new Date(a.end_date || a.date_fin || 0);
|
|
bValue = new Date(b.end_date || b.date_fin || 0);
|
|
}
|
|
|
|
// Appliquer l'ordre
|
|
const comparison = aValue.getTime() - bValue.getTime();
|
|
return order === 'asc' ? comparison : -comparison;
|
|
});
|
|
// Pagination JS après filtrage/tri
|
|
const paged = filtered.slice(offset, offset + limit);
|
|
// Mapping format attendu par le front
|
|
const items = paged.map((row: any) => {
|
|
const isMulti = row.multi_mois === "Oui" || row.multi_mois === true;
|
|
const td = String(row.type_d_embauche || "").toLowerCase();
|
|
const isRG = td.includes("régime général") || td.includes("regime general") || td === "rg";
|
|
return {
|
|
id: row.id,
|
|
reference: row.contract_number,
|
|
salarie_nom: row.employee_name,
|
|
production: row.production_name || row.organizations?.name || "",
|
|
// Expose organization info (if available) so UI can show "Structure" column for staff
|
|
org_id: row.org_id || null,
|
|
org_name: row.organizations?.name || null,
|
|
profession: row.profession || row.role || "",
|
|
date_debut: row.start_date,
|
|
date_fin: row.end_date,
|
|
etat: (row.etat_de_la_demande || row.etat || "en_cours"),
|
|
is_multi_mois: isMulti,
|
|
regime: isRG ? "RG" : (isMulti ? "CDDU_MULTI" : "CDDU_MONO"),
|
|
};
|
|
});
|
|
return NextResponse.json({
|
|
items,
|
|
page,
|
|
limit,
|
|
hasMore: filtered.length > offset + limit,
|
|
total: filtered.length,
|
|
totalPages: Math.ceil(filtered.length / limit),
|
|
debug: {
|
|
orgId,
|
|
isStaff,
|
|
totalRowsFromDB: (data || []).length,
|
|
filteredRows: filtered.length,
|
|
returnedItems: items.length
|
|
}
|
|
});
|
|
}
|
|
// Sinon, proxy comme avant (RG ou autre)
|
|
const upstream = buildUpstreamUrl(req);
|
|
const debug = url.searchParams.get("debug");
|
|
if (debug === "1") {
|
|
const activeOrg = req.headers.get("x-active-org-id") || undefined;
|
|
const companyNameB64 = req.headers.get("x-company-name-b64") || undefined;
|
|
const companyKey = req.headers.get("x-company-key") || undefined;
|
|
const rawCompanyName = req.headers.get("x-company-name") || undefined;
|
|
const incomingAuth = req.headers.get("authorization") || undefined;
|
|
return NextResponse.json({
|
|
upstream: upstream.toString(),
|
|
forwardedHeaders: {
|
|
...(activeOrg ? { "x-active-org-id": activeOrg } : {}),
|
|
...(companyNameB64 ? { "x-company-name-b64": companyNameB64 } : {}),
|
|
...(rawCompanyName ? { "x-company-name": rawCompanyName } : {}),
|
|
...(companyKey ? { "x-company-key": companyKey } : {}),
|
|
...(incomingAuth ? { authorization: incomingAuth } : {}),
|
|
...(process.env.STRUCTURE_API_TOKEN && !incomingAuth
|
|
? { authorization: `Bearer ${process.env.STRUCTURE_API_TOKEN?.slice(0, 8)}…` }
|
|
: {}),
|
|
},
|
|
note: "Set STRUCTURE_API_BASE_URL and optionally STRUCTURE_API_PATH_PREFIX (default '/api').",
|
|
});
|
|
}
|
|
const activeOrg = req.headers.get("x-active-org-id") || undefined;
|
|
const companyNameB64 = req.headers.get("x-company-name-b64") || undefined;
|
|
const companyKey = req.headers.get("x-company-key") || undefined;
|
|
const rawCompanyName = req.headers.get("x-company-name") || undefined;
|
|
const incomingAuth = req.headers.get("authorization") || undefined;
|
|
|
|
const r = await fetch(upstream.toString(), {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
...(activeOrg ? { "x-active-org-id": activeOrg } : {}),
|
|
...(companyNameB64 ? { "x-company-name-b64": companyNameB64 } : {}),
|
|
...(rawCompanyName ? { "x-company-name": rawCompanyName } : {}),
|
|
...(companyKey ? { "x-company-key": companyKey } : {}),
|
|
...(incomingAuth ? { Authorization: incomingAuth } : {}),
|
|
...(process.env.STRUCTURE_API_TOKEN && !incomingAuth
|
|
? { Authorization: `Bearer ${process.env.STRUCTURE_API_TOKEN}` }
|
|
: {}),
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
|
|
const ct = r.headers.get("content-type") || "application/json";
|
|
const body = await r.text();
|
|
|
|
if (!r.ok) {
|
|
return new NextResponse(body || JSON.stringify({ error: "upstream_failed" }), {
|
|
status: r.status,
|
|
headers: { "content-type": ct },
|
|
});
|
|
}
|
|
|
|
return new NextResponse(body, {
|
|
status: 200,
|
|
headers: { "content-type": ct },
|
|
});
|
|
} catch (e: any) {
|
|
// En cas de panne réseau ou d'URL invalide, renvoyer une erreur JSON claire
|
|
return NextResponse.json(
|
|
{ error: "proxy_error", message: e?.message || String(e) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
} |