Ajout filtre org Virements salaires
This commit is contained in:
parent
2f147382b3
commit
4b72b4cc0d
16 changed files with 633 additions and 139 deletions
|
|
@ -37,7 +37,6 @@ export default async function StaffContractsPage() {
|
|||
.select(
|
||||
`id, contract_number, employee_name, structure, type_de_contrat, start_date, end_date, created_at, etat_de_la_demande, etat_de_la_paie, dpae, gross_pay, org_id`
|
||||
)
|
||||
.eq("type_de_contrat", "CDD d'usage")
|
||||
.order("start_date", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ export default async function StaffSalaryTransfersPage() {
|
|||
.order("period_month", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
// Récupérer toutes les organisations pour le filtre
|
||||
const { data: organizations, error: orgError } = await sb
|
||||
.from("organizations")
|
||||
.select("id, name")
|
||||
.order("name", { ascending: true });
|
||||
|
||||
if (orgError) {
|
||||
console.error("[staff/virements-salaires] Erreur chargement organisations:", orgError);
|
||||
}
|
||||
|
||||
// Debug log pour vérifier le nombre d'organisations
|
||||
console.log("[staff/virements-salaires] Organizations count:", organizations?.length, "first 3:", organizations?.slice(0, 3));
|
||||
|
||||
// Server-side debug logging to help diagnose empty results (will appear in Next.js server logs)
|
||||
try {
|
||||
console.log("[staff/virements-salaires] supabase fetch salary_transfers result count:", Array.isArray(salaryTransfers) ? salaryTransfers.length : typeof salaryTransfers);
|
||||
|
|
@ -87,7 +100,11 @@ export default async function StaffSalaryTransfersPage() {
|
|||
</div>
|
||||
)}
|
||||
{/* Client-side interactive grid */}
|
||||
<SalaryTransfersGrid initialData={salaryTransfers ?? []} activeOrgId={activeOrgId} />
|
||||
<SalaryTransfersGrid
|
||||
initialData={salaryTransfers ?? []}
|
||||
activeOrgId={activeOrgId}
|
||||
organizations={organizations ?? []}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ function useUserInfo() {
|
|||
const me = await res.json();
|
||||
|
||||
return {
|
||||
isStaff: Boolean(me.isStaff),
|
||||
orgId: me.orgId || me.active_org_id || "unknown",
|
||||
isStaff: Boolean(me.is_staff || me.isStaff),
|
||||
orgId: me.orgId || me.active_org_id || null,
|
||||
orgName: me.orgName || me.active_org_name || "Organisation",
|
||||
api_name: me.active_org_api_name
|
||||
};
|
||||
|
|
@ -182,9 +182,9 @@ function useUserInfo() {
|
|||
}
|
||||
|
||||
function useOrganizations() {
|
||||
const { data: userInfo } = useUserInfo();
|
||||
const { data: userInfo, isSuccess: userInfoLoaded } = useUserInfo();
|
||||
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ["organizations"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
|
|
@ -200,9 +200,17 @@ function useOrganizations() {
|
|||
return [];
|
||||
}
|
||||
},
|
||||
enabled: Boolean(userInfo?.isStaff),
|
||||
enabled: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (userInfoLoaded && userInfo?.isStaff && !query.data && !query.isFetching) {
|
||||
query.refetch();
|
||||
}
|
||||
}, [userInfo, userInfoLoaded, query]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// --- Hook pour récupérer les virements ---
|
||||
|
|
@ -268,9 +276,8 @@ export default function VirementsPage() {
|
|||
const [copiedField, setCopiedField] = useState<null | 'iban' | 'bic' | 'benef'>(null);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
|
||||
// Récupération des informations utilisateur et des organisations (pour le staff)
|
||||
const { data: userInfo } = useUserInfo();
|
||||
const { data: organizations } = useOrganizations();
|
||||
const { data: userInfo, isLoading: isLoadingUser } = useUserInfo();
|
||||
const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const years = useMemo(() => {
|
||||
|
|
@ -384,15 +391,6 @@ export default function VirementsPage() {
|
|||
const clientUnpaidAll: ClientVirementItem[] = (data?.client?.unpaid ?? []) as ClientVirementItem[];
|
||||
const clientRecentAll: ClientVirementItem[] = (data?.client?.recent ?? []) as ClientVirementItem[];
|
||||
|
||||
// Debug logging
|
||||
console.log("🔍 [virements-page] Debug data:", {
|
||||
isOdentas,
|
||||
itemsCount: items.length,
|
||||
clientUnpaidCount: clientUnpaidAll.length,
|
||||
clientRecentCount: clientRecentAll.length,
|
||||
rawData: data
|
||||
});
|
||||
|
||||
const clientFilter = (arr: ClientVirementItem[]) => {
|
||||
if (!searchQuery.trim()) return arr;
|
||||
const q = searchQuery.toLowerCase();
|
||||
|
|
@ -521,19 +519,27 @@ export default function VirementsPage() {
|
|||
</div>
|
||||
|
||||
{/* Sélecteur d'organisation (visible uniquement par le staff) */}
|
||||
{userInfo?.isStaff && organizations && organizations.length > 0 && (
|
||||
{userInfo?.isStaff && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium">Organisation :</label>
|
||||
<select
|
||||
className="px-3 py-2 rounded-lg border bg-white text-sm min-w-[200px]"
|
||||
value={selectedOrgId}
|
||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||
disabled={!organizations || organizations.length === 0}
|
||||
>
|
||||
<option value="">Toutes les organisations</option>
|
||||
{organizations.map((org: any) => (
|
||||
<option value="">
|
||||
{!organizations || organizations.length === 0
|
||||
? "Chargement..."
|
||||
: "Toutes les organisations"}
|
||||
</option>
|
||||
{organizations && organizations.map((org: any) => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<span className="text-xs text-slate-500">({organizations.length})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -365,8 +365,8 @@ export async function POST(
|
|||
cachets: {
|
||||
representations: contract.cachets_representations ? parseInt(contract.cachets_representations) || 0 : 0,
|
||||
repetitions: contract.services_repetitions ? parseInt(contract.services_repetitions) || 0 : 0,
|
||||
heures: contract.nombre_d_heures ? parseInt(contract.nombre_d_heures) || 0 : 0,
|
||||
heuresparjour: contract.nombre_d_heures_par_jour ? parseInt(contract.nombre_d_heures_par_jour) || 0 : 0
|
||||
heures: contract.nombre_d_heures ? parseFloat(contract.nombre_d_heures) || 0 : 0,
|
||||
heuresparjour: contract.nombre_d_heures_par_jour ? parseFloat(contract.nombre_d_heures_par_jour) || 0 : 0
|
||||
},
|
||||
nom_responsable_traitement: orgDetails.nom_responsable_traitement || "",
|
||||
qualite_responsable_traitement: orgDetails.qualite_responsable_traitement || "",
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
|
|||
const { data: orgDetails } = await supabase
|
||||
.from('organization_details')
|
||||
.select('*')
|
||||
.eq('organization_id', contract.org_id)
|
||||
.eq('org_id', contract.org_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (orgDetails) {
|
||||
|
|
@ -115,6 +115,9 @@ export async function POST(request: NextRequest) {
|
|||
signerName = `${orgDetails.prenom_signataire || ""} ${orgDetails.nom_signataire || ""}`.trim() || signerName;
|
||||
organizationName = orgDetails.structure || organizationName;
|
||||
employerCode = orgDetails.code_employeur || employerCode;
|
||||
console.log(`✅ Organisation trouvée: ${organizationName}, email: ${employerEmail}`);
|
||||
} else {
|
||||
console.log(`⚠️ Aucun orgDetails trouvé pour org_id: ${contract.org_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,14 @@ export async function GET(req: Request) {
|
|||
}
|
||||
if (employee_matricule) query = query.eq("employee_matricule", employee_matricule);
|
||||
if (structure) query = query.eq("structure", structure);
|
||||
if (type_de_contrat) query = query.eq("type_de_contrat", type_de_contrat);
|
||||
|
||||
// Handle special "RG" filter for common law contracts (CDD de droit commun + CDI)
|
||||
if (type_de_contrat === "RG") {
|
||||
query = query.in("type_de_contrat", ["CDD de droit commun", "CDI"]);
|
||||
} else if (type_de_contrat) {
|
||||
query = query.eq("type_de_contrat", type_de_contrat);
|
||||
}
|
||||
|
||||
if (etat_de_la_demande) query = query.eq("etat_de_la_demande", etat_de_la_demande);
|
||||
if (etat_de_la_paie) query = query.eq("etat_de_la_paie", etat_de_la_paie);
|
||||
if (dpae) query = query.eq("dpae", dpae);
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export async function POST(request: NextRequest) {
|
|||
employerCode: employerCode,
|
||||
contractCount: contractCount,
|
||||
status: 'En attente de signature',
|
||||
ctaUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'https://staging.paie.odentas.fr'}/signatures-electroniques`,
|
||||
ctaUrl: 'https://paie.odentas.fr/signatures-electroniques',
|
||||
handlerName: 'Renaud BREVIERE-ABRAHAM',
|
||||
organizationId: organizationId // Ajouter pour le logging
|
||||
};
|
||||
|
|
|
|||
141
app/api/staff/payslip-upload/route.ts
Normal file
141
app/api/staff/payslip-upload/route.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createSbServer } from "@/lib/supabaseServer";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || "eu-west-3",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
||||
},
|
||||
});
|
||||
|
||||
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const sb = createSbServer();
|
||||
|
||||
// Vérifier que l'utilisateur est staff
|
||||
const { data: { user } } = await sb.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: staffUser } = await sb
|
||||
.from("staff_users")
|
||||
.select("is_staff")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!staffUser?.is_staff) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Parser le form data
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const contractId = formData.get('contract_id') as string;
|
||||
const payslipId = formData.get('payslip_id') as string;
|
||||
|
||||
if (!file || !contractId || !payslipId) {
|
||||
return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Vérifier que c'est bien un PDF
|
||||
if (file.type !== 'application/pdf') {
|
||||
return NextResponse.json({ error: "Seuls les fichiers PDF sont acceptés" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Récupérer les informations du contrat pour construire le chemin S3
|
||||
const { data: contract, error: contractError } = await sb
|
||||
.from("cddu_contracts")
|
||||
.select("contract_number, org_id, employee_name")
|
||||
.eq("id", contractId)
|
||||
.single();
|
||||
|
||||
if (contractError || !contract) {
|
||||
return NextResponse.json({ error: "Contrat introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Récupérer les informations de la paie
|
||||
const { data: payslip, error: payslipError } = await sb
|
||||
.from("payslips")
|
||||
.select("pay_number")
|
||||
.eq("id", payslipId)
|
||||
.single();
|
||||
|
||||
if (payslipError || !payslip) {
|
||||
return NextResponse.json({ error: "Paie introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Récupérer l'organization pour avoir l'org_key
|
||||
const { data: org, error: orgError } = await sb
|
||||
.from("organizations")
|
||||
.select("api_name")
|
||||
.eq("id", contract.org_id)
|
||||
.single();
|
||||
|
||||
if (orgError || !org?.api_name) {
|
||||
return NextResponse.json({ error: "Organisation introuvable" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Générer le chemin S3: bulletins/{org_key}/contrat_{contract_number}/bulletin_paie_{pay_number}_{uuid}.pdf
|
||||
const uniqueId = uuidv4().replace(/-/g, '').substring(0, 8);
|
||||
const contractNumber = contract.contract_number || contractId.substring(0, 8);
|
||||
const payNumber = payslip.pay_number || 'unknown';
|
||||
const filename = `bulletin_paie_${payNumber}_${uniqueId}.pdf`;
|
||||
const s3Key = `bulletins/${org.api_name}/contrat_${contractNumber}/${filename}`;
|
||||
|
||||
console.log('📄 [Payslip Upload] Uploading to S3:', {
|
||||
contractId,
|
||||
payslipId,
|
||||
contractNumber,
|
||||
payNumber,
|
||||
s3Key,
|
||||
fileSize: file.size
|
||||
});
|
||||
|
||||
// Upload vers S3
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: BUCKET_NAME,
|
||||
Key: s3Key,
|
||||
Body: buffer,
|
||||
ContentType: 'application/pdf',
|
||||
});
|
||||
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
// Mettre à jour la paie dans Supabase avec le chemin S3
|
||||
const { error: updateError } = await sb
|
||||
.from('payslips')
|
||||
.update({
|
||||
bulletin_pdf_url: s3Key,
|
||||
bulletin_uploaded_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', payslipId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('❌ [Payslip Upload] Erreur mise à jour Supabase:', updateError);
|
||||
return NextResponse.json({ error: "Erreur lors de la mise à jour de la base de données" }, { status: 500 });
|
||||
}
|
||||
|
||||
console.log('✅ [Payslip Upload] Upload réussi:', s3Key);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
s3_key: s3Key,
|
||||
filename: filename,
|
||||
message: "Bulletin de paie uploadé avec succès"
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Payslip Upload] Erreur:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Erreur serveur" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,35 @@ async function assertStaff(sb: ReturnType<typeof createSbServer>, userId: string
|
|||
return !!me?.is_staff;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const supabase = createSbServer();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
|
||||
const isStaff = await assertStaff(supabase, user.id);
|
||||
if (!isStaff) return NextResponse.json({ error: "Accès réservé au staff." }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const contractId = searchParams.get('contract_id');
|
||||
|
||||
if (!contractId) {
|
||||
return NextResponse.json({ error: "contract_id manquant" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: payslips, error } = await supabase
|
||||
.from("payslips")
|
||||
.select("*")
|
||||
.eq("contract_id", contractId)
|
||||
.order("pay_number", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Erreur récupération payslips:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ payslips: payslips ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const supabase = createSbServer();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export async function GET(req: Request) {
|
|||
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q");
|
||||
const org_id = url.searchParams.get("org_id");
|
||||
const period_month = url.searchParams.get("period_month");
|
||||
const mode = url.searchParams.get("mode");
|
||||
const notification_sent = url.searchParams.get("notification_sent");
|
||||
|
|
@ -31,6 +32,7 @@ export async function GET(req: Request) {
|
|||
// simple ilike search on period_label, callsheet_url, notes
|
||||
query = query.or(`period_label.ilike.%${q}%,callsheet_url.ilike.%${q}%,notes.ilike.%${q}%`);
|
||||
}
|
||||
if (org_id) query = query.eq("org_id", org_id);
|
||||
if (period_month) query = query.eq("period_month", period_month);
|
||||
if (mode) query = query.eq("mode", mode);
|
||||
if (notification_sent !== null) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// components/staff/BulkESignConfirmModal.tsx
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { X, FileSignature, AlertTriangle } from "lucide-react";
|
||||
|
||||
type BulkESignConfirmModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (sendNotification: boolean) => void;
|
||||
contractCount: number;
|
||||
};
|
||||
|
||||
|
|
@ -17,6 +17,8 @@ export default function BulkESignConfirmModal({
|
|||
onConfirm,
|
||||
contractCount
|
||||
}: BulkESignConfirmModalProps) {
|
||||
const [sendNotification, setSendNotification] = useState(true);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -49,18 +51,38 @@ export default function BulkESignConfirmModal({
|
|||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mb-6 text-sm text-gray-600 space-y-2">
|
||||
<div className="mb-4 text-sm text-gray-600 space-y-2">
|
||||
<p>Cette action va :</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Créer une demande de signature électronique pour chaque contrat</li>
|
||||
<li>Envoyer un email à l'employeur et au salarié pour chaque contrat</li>
|
||||
<li>Envoyer un email récapitulatif au client</li>
|
||||
{sendNotification && <li className="text-indigo-600 font-medium">Envoyer un email récapitulatif au client</li>}
|
||||
</ul>
|
||||
<p className="mt-3 font-medium">
|
||||
Assurez-vous que tous les contrats ont un PDF généré et que les salariés ont un email renseigné.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Option: Envoyer notification */}
|
||||
<div className="mb-6 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendNotification}
|
||||
onChange={(e) => setSendNotification(e.target.checked)}
|
||||
className="mt-0.5 size-4 text-indigo-600 rounded focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Envoyer un email récapitulatif au client
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Si décoché, les signatures seront créées dans DocuSeal mais aucun email de notification groupée ne sera envoyé à l'employeur.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
|
|
@ -70,7 +92,7 @@ export default function BulkESignConfirmModal({
|
|||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
onClick={() => onConfirm(sendNotification)}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700"
|
||||
>
|
||||
Confirmer et envoyer
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
// filters / sorting / pagination - with localStorage persistence
|
||||
const [q, setQ] = useState(savedFilters?.q || "");
|
||||
const [structureFilter, setStructureFilter] = useState<string | null>(savedFilters?.structureFilter || null);
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || "CDD d'usage");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(savedFilters?.typeFilter || null);
|
||||
const [etatContratFilter, setEtatContratFilter] = useState<string | null>(savedFilters?.etatContratFilter || null);
|
||||
const [etatPaieFilter, setEtatPaieFilter] = useState<string | null>(savedFilters?.etatPaieFilter || null);
|
||||
const [dpaeFilter, setDpaeFilter] = useState<string | null>(savedFilters?.dpaeFilter || null);
|
||||
|
|
@ -328,7 +328,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
|
||||
// Apply local sorting when using initial data
|
||||
const sortedRows = useMemo(() => {
|
||||
const noFilters = !q && !structureFilter && typeFilter === "CDD d'usage" && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo;
|
||||
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo;
|
||||
|
||||
if (noFilters) {
|
||||
// Utiliser le tri local pour les données initiales
|
||||
|
|
@ -605,7 +605,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
};
|
||||
|
||||
// Fonction pour générer les e-signatures en masse (après confirmation)
|
||||
const generateBatchESign = async () => {
|
||||
const generateBatchESign = async (sendNotification: boolean) => {
|
||||
// Fermer le modal de confirmation
|
||||
setShowESignConfirmModal(false);
|
||||
|
||||
|
|
@ -746,8 +746,8 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
if (successCount > 0) {
|
||||
toast.success(`${successCount} signature${successCount > 1 ? 's' : ''} électronique${successCount > 1 ? 's' : ''} créée${successCount > 1 ? 's' : ''} !`);
|
||||
|
||||
// Envoyer un mail récapitulatif au client (email_notifs + CC)
|
||||
if (successfulContracts.length > 0) {
|
||||
// Envoyer un mail récapitulatif au client (email_notifs + CC) si demandé
|
||||
if (sendNotification && successfulContracts.length > 0) {
|
||||
try {
|
||||
console.log('📧 [E-SIGN] Début envoi emails récapitulatifs...');
|
||||
console.log('📧 [E-SIGN] successfulContracts:', successfulContracts);
|
||||
|
|
@ -797,6 +797,8 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
console.error("Erreur lors de l'envoi du mail récapitulatif:", emailError);
|
||||
toast.warning("Signatures créées mais erreur lors de l'envoi de l'email récapitulatif");
|
||||
}
|
||||
} else if (!sendNotification) {
|
||||
console.log('📧 [E-SIGN] Email récapitulatif désactivé par l\'utilisateur');
|
||||
}
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
|
|
@ -834,7 +836,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
// Debounce searches when filters change
|
||||
useEffect(() => {
|
||||
// if no filters applied, prefer initial data
|
||||
const noFilters = !q && !structureFilter && typeFilter === "CDD d'usage" && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
|
||||
const noFilters = !q && !structureFilter && !typeFilter && !etatContratFilter && !etatPaieFilter && !dpaeFilter && !startFrom && !startTo && sortField === 'start_date' && sortOrder === 'desc';
|
||||
if (noFilters) {
|
||||
setRows(initialData || []);
|
||||
return;
|
||||
|
|
@ -846,7 +848,10 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
}, [q, structureFilter, typeFilter, etatContratFilter, etatPaieFilter, dpaeFilter, startFrom, startTo, sortField, sortOrder, limit]);
|
||||
|
||||
// derive options from initialData for simple selects
|
||||
const structures = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.structure).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
||||
const structures = useMemo(() => {
|
||||
const uniqueStructures = Array.from(new Set((initialData || []).map((r) => r.structure).filter(Boolean) as string[]));
|
||||
return uniqueStructures.sort((a, b) => a.localeCompare(b, 'fr')).slice(0, 50);
|
||||
}, [initialData]);
|
||||
const types = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.type_de_contrat).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
||||
const etatsContrat = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_demande).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
||||
const etatsPaie = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.etat_de_la_paie).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
||||
|
|
@ -874,7 +879,9 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
</select>
|
||||
<select value={typeFilter ?? ""} onChange={(e) => setTypeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">Tous types</option>
|
||||
{types.map((t) => (<option key={t} value={t}>{t === "CDD d'usage" ? "CDDU" : t}</option>))}
|
||||
<option value="CDD d'usage">CDDU</option>
|
||||
<option value="RG">RG (CDD/CDI droit commun)</option>
|
||||
{types.filter(t => t !== "CDD d'usage" && t !== "CDD de droit commun" && t !== "CDI").map((t) => (<option key={t} value={t}>{t}</option>))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
|
|
@ -899,7 +906,7 @@ export default function ContractsGrid({ initialData, activeOrgId }: { initialDat
|
|||
onClick={() => {
|
||||
setQ('');
|
||||
setStructureFilter(null);
|
||||
setTypeFilter("CDD d'usage");
|
||||
setTypeFilter(null);
|
||||
setEtatContratFilter(null);
|
||||
setEtatPaieFilter(null);
|
||||
setDpaeFilter(null);
|
||||
|
|
|
|||
|
|
@ -49,12 +49,32 @@ type SalaryTransfer = {
|
|||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export default function SalaryTransfersGrid({ initialData, activeOrgId }: { initialData: SalaryTransfer[]; activeOrgId?: string | null }) {
|
||||
type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function SalaryTransfersGrid({
|
||||
initialData,
|
||||
activeOrgId,
|
||||
organizations = []
|
||||
}: {
|
||||
initialData: SalaryTransfer[];
|
||||
activeOrgId?: string | null;
|
||||
organizations?: Organization[];
|
||||
}) {
|
||||
const [rows, setRows] = useState<SalaryTransfer[]>(initialData || []);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Debug log pour vérifier que les organisations sont bien passées
|
||||
useEffect(() => {
|
||||
console.log("[SalaryTransfersGrid] Organizations received:", organizations?.length, organizations);
|
||||
}, [organizations]);
|
||||
|
||||
// filters / sorting / pagination
|
||||
const [q, setQ] = useState("");
|
||||
const [orgFilter, setOrgFilter] = useState<string | null>(null);
|
||||
const [modeFilter, setModeFilter] = useState<string | null>(null);
|
||||
const [notificationSentFilter, setNotificationSentFilter] = useState<string | null>(null);
|
||||
const [notificationOkFilter, setNotificationOkFilter] = useState<string | null>(null);
|
||||
|
|
@ -147,6 +167,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
|
|||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (q) params.set('q', q);
|
||||
if (orgFilter) params.set('org_id', orgFilter);
|
||||
if (modeFilter) params.set('mode', modeFilter);
|
||||
if (notificationSentFilter) params.set('notification_sent', notificationSentFilter);
|
||||
if (notificationOkFilter) params.set('notification_ok', notificationOkFilter);
|
||||
|
|
@ -174,7 +195,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
|
|||
// Debounce searches when filters change
|
||||
useEffect(() => {
|
||||
// if no filters applied, prefer initial data
|
||||
const noFilters = !q && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
|
||||
const noFilters = !q && !orgFilter && !modeFilter && !notificationSentFilter && !notificationOkFilter && !hasClientWireFilter && !deadlineFrom && !deadlineTo && sortField === 'period_month' && sortOrder === 'desc';
|
||||
if (noFilters) {
|
||||
setRows(initialData || []);
|
||||
return;
|
||||
|
|
@ -183,7 +204,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
|
|||
const t = setTimeout(() => fetchServer(0), 300);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [q, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
|
||||
}, [q, orgFilter, modeFilter, notificationSentFilter, notificationOkFilter, hasClientWireFilter, deadlineFrom, deadlineTo, sortField, sortOrder, limit]);
|
||||
|
||||
// derive options from initialData for simple selects
|
||||
const modes = useMemo(() => Array.from(new Set((initialData || []).map((r) => r.mode).filter(Boolean) as string[])).slice(0,50), [initialData]);
|
||||
|
|
@ -204,6 +225,28 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filtres rapides toujours visibles */}
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-xs font-medium text-slate-700">Organisation:</label>
|
||||
<select
|
||||
value={orgFilter ?? ""}
|
||||
onChange={(e) => setOrgFilter(e.target.value || null)}
|
||||
className="rounded border px-3 py-2 text-sm bg-white min-w-[200px]"
|
||||
disabled={!organizations || organizations.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!organizations || organizations.length === 0
|
||||
? "Chargement..."
|
||||
: "Toutes les organisations"}
|
||||
</option>
|
||||
{organizations && organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{organizations && organizations.length > 0 && (
|
||||
<span className="text-xs text-slate-500">({organizations.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select value={modeFilter ?? ""} onChange={(e) => setModeFilter(e.target.value || null)} className="rounded border px-2 py-1 text-sm">
|
||||
<option value="">Tous modes</option>
|
||||
{modes.map((m) => (<option key={m} value={m}>{m}</option>))}
|
||||
|
|
@ -220,6 +263,7 @@ export default function SalaryTransfersGrid({ initialData, activeOrgId }: { init
|
|||
className="rounded border px-3 py-1 text-sm"
|
||||
onClick={() => {
|
||||
setQ('');
|
||||
setOrgFilter(null);
|
||||
setModeFilter(null);
|
||||
setNotificationSentFilter(null);
|
||||
setNotificationOkFilter(null);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { PROFESSIONS_ARTISTE } from "@/components/constants/ProfessionsArtiste";
|
|||
import { LoadingModal } from "@/components/ui/loading-modal";
|
||||
import { PayslipModal } from "./PayslipModal";
|
||||
import { EmployerReminderModal } from "./EmployerReminderModal";
|
||||
import { PayslipCard } from "./PayslipCard";
|
||||
|
||||
type AnyObj = Record<string, any>;
|
||||
|
||||
|
|
@ -1011,6 +1012,23 @@ export default function ContractEditor({
|
|||
window.location.reload(); // Pour l'instant, on recharge. Idéalement on ferait un refetch
|
||||
};
|
||||
|
||||
// Fonction pour rafraîchir les payslips après un upload
|
||||
const handlePayslipUploadComplete = async () => {
|
||||
try {
|
||||
// Recharger les payslips depuis la base de données
|
||||
const response = await fetch(`/api/staff/payslips?contract_id=${contract.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRows(data.payslips ?? []);
|
||||
toast.success("Données mises à jour");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du rafraîchissement des payslips:", error);
|
||||
// En cas d'erreur, on recharge la page
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour marquer le contrat comme non signé
|
||||
const [isMarkingUnsigned, setIsMarkingUnsigned] = useState(false);
|
||||
|
||||
|
|
@ -2066,103 +2084,14 @@ export default function ContractEditor({
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((payslip, i) => (
|
||||
<div
|
||||
<PayslipCard
|
||||
key={payslip.id ?? i}
|
||||
payslip={payslip}
|
||||
index={i}
|
||||
contractId={contract.id}
|
||||
onClick={() => handleOpenPayslipModal(payslip)}
|
||||
className="border rounded-2xl p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* En-tête: numéro, période et date de paie */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
#{payslip.pay_number || i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
{payslip.period_start && payslip.period_end ? (
|
||||
`${new Date(payslip.period_start).toLocaleDateString('fr-FR')} - ${new Date(payslip.period_end).toLocaleDateString('fr-FR')}`
|
||||
) : (
|
||||
"Période non définie"
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{payslip.pay_date ? `Paie du ${new Date(payslip.pay_date).toLocaleDateString('fr-FR')}` : "Date non définie"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Montants organisés en 2x2 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Brut</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Net avant PAS</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.net_amount ? `${parseFloat(payslip.net_amount).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Net à payer</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Coût employeur</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.employer_cost ? `${parseFloat(payslip.employer_cost).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicateurs en bas */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Indicateur traitement */}
|
||||
{!payslip.processed ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600">
|
||||
À traiter
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600">
|
||||
Traitée
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Indicateur virement */}
|
||||
{payslip.processed && (
|
||||
payslip.transfer_done ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-600">
|
||||
Virement OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-50 text-orange-600">
|
||||
Virement en attente
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Indicateur AEM */}
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
payslip.aem_status === 'OK'
|
||||
? 'bg-green-50 text-green-600'
|
||||
: 'bg-yellow-50 text-yellow-600'
|
||||
}`}>
|
||||
AEM: {payslip.aem_status || 'À traiter'}
|
||||
</span>
|
||||
|
||||
{/* Indicateur PDF */}
|
||||
{payslip.storage_url && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-50 text-blue-600">
|
||||
PDF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
onUploadComplete={handlePayslipUploadComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
273
components/staff/contracts/PayslipCard.tsx
Normal file
273
components/staff/contracts/PayslipCard.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, DragEvent } from "react";
|
||||
import { CheckCircle2, Upload, FileText, X, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type PayslipCardProps = {
|
||||
payslip: any;
|
||||
index: number;
|
||||
contractId: string;
|
||||
onClick?: () => void;
|
||||
onUploadComplete?: () => void;
|
||||
};
|
||||
|
||||
export function PayslipCard({ payslip, index, contractId, onClick, onUploadComplete }: PayslipCardProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Vérifier que c'est un PDF
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast.error("Seuls les fichiers PDF sont acceptés");
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFile(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Vérifier que c'est un PDF
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast.error("Seuls les fichiers PDF sont acceptés");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadFile(file);
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('contract_id', contractId);
|
||||
formData.append('payslip_id', payslip.id);
|
||||
|
||||
const response = await fetch('/api/staff/payslip-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Erreur lors de l\'upload');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
toast.success("Bulletin de paie uploadé avec succès !");
|
||||
|
||||
// Notifier le parent pour rafraîchir les données
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur upload:', error);
|
||||
toast.error(error instanceof Error ? error.message : "Erreur lors de l'upload");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset le file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Ne pas déclencher onClick si on clique sur la zone d'upload
|
||||
if (isUploading || isDragging) {
|
||||
return;
|
||||
}
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAreaClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const hasPdf = !!payslip.bulletin_pdf_url;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-2xl p-4 transition-all ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-50 border-2'
|
||||
: hasPdf
|
||||
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||
: 'hover:bg-gray-50'
|
||||
} ${!isUploading ? 'cursor-pointer' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* En-tête: numéro, période et date de paie */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
#{payslip.pay_number || index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
{payslip.period_start && payslip.period_end ? (
|
||||
`${new Date(payslip.period_start).toLocaleDateString('fr-FR')} - ${new Date(payslip.period_end).toLocaleDateString('fr-FR')}`
|
||||
) : (
|
||||
"Période non définie"
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{payslip.pay_date ? `Paie du ${new Date(payslip.pay_date).toLocaleDateString('fr-FR')}` : "Date non définie"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicateur de bulletin PDF */}
|
||||
{hasPdf && (
|
||||
<div className="flex items-center gap-2 bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium">
|
||||
<CheckCircle2 className="size-4" />
|
||||
Bulletin uploadé
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Montants organisés en 2x2 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Brut</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.gross_amount ? `${parseFloat(payslip.gross_amount).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Net avant PAS</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.net_amount ? `${parseFloat(payslip.net_amount).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Net à payer</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.net_after_withholding ? `${parseFloat(payslip.net_after_withholding).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Coût employeur</div>
|
||||
<div className="font-semibold text-gray-900">
|
||||
{payslip.employer_cost ? `${parseFloat(payslip.employer_cost).toFixed(2)}€` : "Non défini"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de drag & drop ou bouton d'upload */}
|
||||
{!hasPdf && (
|
||||
<div
|
||||
onClick={handleUploadAreaClick}
|
||||
className={`border-2 border-dashed rounded-xl p-4 text-center transition-all ${
|
||||
isDragging
|
||||
? 'border-blue-500 bg-blue-100'
|
||||
: 'border-gray-300 bg-gray-50 hover:border-blue-400 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="size-6 text-blue-500 animate-spin" />
|
||||
<p className="text-sm text-blue-600 font-medium">Upload en cours...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="size-6 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium text-blue-600">Cliquez</span> ou glissez un PDF ici
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Bulletin de paie au format PDF</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateurs en bas */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Indicateur traitement */}
|
||||
{!payslip.processed ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-50 text-red-600">
|
||||
À traiter
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-50 text-green-600">
|
||||
Traitée
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Indicateur virement */}
|
||||
{payslip.processed && (
|
||||
payslip.transfer_done ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-600">
|
||||
Virement OK
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-50 text-orange-600">
|
||||
Virement en attente
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Indicateur AEM */}
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
payslip.aem_status === 'OK'
|
||||
? 'bg-green-50 text-green-600'
|
||||
: payslip.aem_status === 'KO'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-gray-50 text-gray-600'
|
||||
}`}>
|
||||
AEM: {payslip.aem_status || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration: Ajouter 'bulk-signature-notification' à l'enum email_type
|
||||
-- Date: 2025-10-13
|
||||
-- Description: Ajoute le type d'email pour les notifications groupées de signature électronique
|
||||
|
||||
-- Ajouter la nouvelle valeur à l'enum email_type si elle n'existe pas déjà
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = 'bulk-signature-notification'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'email_type')
|
||||
) THEN
|
||||
ALTER TYPE email_type ADD VALUE 'bulk-signature-notification';
|
||||
END IF;
|
||||
END $$;
|
||||
Loading…
Reference in a new issue