296 lines
9 KiB
TypeScript
296 lines
9 KiB
TypeScript
import { NotebookPenIcon } from "lucide-react";
|
|
import {
|
|
AllCommunityModule,
|
|
ColDef,
|
|
ModuleRegistry,
|
|
themeQuartz,
|
|
} from "ag-grid-community";
|
|
import { AgGridReact } from "ag-grid-react";
|
|
import {
|
|
useDevisList,
|
|
useCreateDevis,
|
|
useDeleteDevis,
|
|
useUpdateDevis,
|
|
} from "@ui/hooks/devis";
|
|
import { useState } from "react";
|
|
import { Database } from "@ui/types/database.types";
|
|
import { CalendarDate, DateValue } from "@internationalized/date";
|
|
import { RowActionMenu } from "@ui/components/RowActionMenu";
|
|
import { CustomLoadingOverlay } from "@ui/components/CustomLoadingOverlay";
|
|
import {
|
|
EmptyState,
|
|
EmptyStateActions,
|
|
EmptyStateDescription,
|
|
EmptyStateHeading,
|
|
EmptyStateIcon,
|
|
} from "@ui/ui-library/empty-state";
|
|
import { CreateDevisModal } from "@ui/components/devis/CreateDevisModal";
|
|
import {
|
|
calculateTotal,
|
|
calculateTax,
|
|
exportDevisToPdf,
|
|
statusToText,
|
|
} from "@ui/utils/helpers";
|
|
import { ViewDevisModal } from "@ui/components/devis/ViewDevisModal";
|
|
import { Badge, BadgeColor } from "@ui/ui-library/badge";
|
|
import { Select, SelectButton } from "@ui/ui-library/select";
|
|
import { SelectListBox } from "@ui/ui-library/select";
|
|
import { SelectPopover } from "@ui/ui-library/select";
|
|
import { SelectListItem } from "@ui/ui-library/select";
|
|
|
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
|
|
|
type Devis = Database["public"]["Tables"]["devis"]["Row"];
|
|
|
|
const statusToVariant: Record<Devis["status"], BadgeColor> = {
|
|
draft: "zinc",
|
|
sent: "indigo",
|
|
accepted: "green",
|
|
rejected: "red",
|
|
expired: "yellow",
|
|
};
|
|
|
|
export const DevisPage = () => {
|
|
const { data: devisData, isLoading } = useDevisList();
|
|
const createDevis = useCreateDevis();
|
|
const updateDevis = useUpdateDevis();
|
|
const deleteDevis = useDeleteDevis();
|
|
const [dueDateError, setDueDateError] = useState("");
|
|
const [selectedDevis, setSelectedDevis] = useState<Devis | null>(null);
|
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
|
const validateDueDate = (date: DateValue, dueDate: DateValue) => {
|
|
if (dueDate.compare(date) < 0) {
|
|
return "La date d'échéance doit être postérieure à la date de création";
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const handleCreate = (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
const form = event.currentTarget;
|
|
const payload = Object.fromEntries(new FormData(form));
|
|
|
|
const email = payload.client_email as string;
|
|
const date = payload.date as string;
|
|
const due_date = payload.due_date as string;
|
|
const notes = payload.notes as string;
|
|
const terms = payload.terms as string;
|
|
const amount = parseFloat(payload.amount as string) || 0;
|
|
const tax_rate = parseFloat(payload.tax_rate as string) || 0;
|
|
const tax = calculateTax(amount, tax_rate);
|
|
const total = calculateTotal(amount, tax);
|
|
|
|
const dueDateError = validateDueDate(
|
|
new CalendarDate(
|
|
parseInt(date.split("-")[0]),
|
|
parseInt(date.split("-")[1]) - 1,
|
|
parseInt(date.split("-")[2])
|
|
),
|
|
new CalendarDate(
|
|
parseInt(due_date.split("-")[0]),
|
|
parseInt(due_date.split("-")[1]) - 1,
|
|
parseInt(due_date.split("-")[2])
|
|
)
|
|
);
|
|
|
|
setDueDateError(dueDateError);
|
|
|
|
if (dueDateError) {
|
|
return;
|
|
}
|
|
|
|
createDevis.mutate({
|
|
client_email: email,
|
|
date,
|
|
due_date,
|
|
notes,
|
|
terms,
|
|
status: "draft",
|
|
subtotal: amount,
|
|
tax,
|
|
total,
|
|
items: [],
|
|
number: `DEV-${Date.now()}`,
|
|
});
|
|
form.reset();
|
|
};
|
|
|
|
// const handleEdit = (devis: Devis) => {
|
|
// console.log("Edit devis:", devis);
|
|
// };
|
|
|
|
const confirmDeleteAction = (devisId: string) => {
|
|
deleteDevis.mutate(devisId);
|
|
};
|
|
|
|
// Add handler for exporting
|
|
const handleExport = exportDevisToPdf;
|
|
|
|
const columnDefs: ColDef<Devis>[] = [
|
|
{
|
|
field: "date",
|
|
headerName: "Date",
|
|
valueFormatter: (params) => {
|
|
if (!params.value) return "";
|
|
return new Date(params.value).toLocaleDateString("fr-FR");
|
|
},
|
|
},
|
|
{ field: "client_email", headerName: "Client" },
|
|
{
|
|
field: "tax",
|
|
headerName: "TVA",
|
|
valueFormatter: (params) => {
|
|
if (params.value == null) return "";
|
|
return params.value.toFixed(2) + " €";
|
|
},
|
|
flex: 1,
|
|
},
|
|
{
|
|
field: "total",
|
|
headerName: "Montant",
|
|
valueFormatter: (params) => {
|
|
return params.value.toFixed(2) + " €";
|
|
},
|
|
},
|
|
{
|
|
field: "status",
|
|
headerName: "Status",
|
|
cellStyle: { padding: 4 },
|
|
cellRenderer: (params: { data: Devis }) => {
|
|
const currentStatus = params.data.status;
|
|
|
|
return (
|
|
<Select
|
|
className="flex flex-col justify-center"
|
|
aria-label="Status"
|
|
selectedKey={currentStatus}
|
|
onSelectionChange={(key) => {
|
|
updateDevis.mutate({
|
|
id: params.data.id,
|
|
status: key as Devis["status"],
|
|
});
|
|
}}
|
|
>
|
|
<SelectButton className="w-32 h-8" />
|
|
<SelectPopover>
|
|
<SelectListBox checkIconPlacement="start">
|
|
{Object.entries(statusToVariant).map(([status]) => (
|
|
<SelectListItem key={status} id={status} textValue={status}>
|
|
<Badge color={statusToVariant[status as Devis["status"]]}>
|
|
{statusToText[status as Devis["status"]]}
|
|
</Badge>
|
|
</SelectListItem>
|
|
))}
|
|
</SelectListBox>
|
|
</SelectPopover>
|
|
</Select>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
headerName: "Actions",
|
|
pinned: "right",
|
|
width: 130,
|
|
cellStyle: { padding: 2 },
|
|
colId: "actions-column",
|
|
cellRenderer: (params: { data: Devis; node: { id: string | null } }) => {
|
|
if (!params.data) return null;
|
|
return (
|
|
<div className="flex justify-center items-center h-full">
|
|
<RowActionMenu
|
|
devis={params.data}
|
|
onDelete={confirmDeleteAction}
|
|
onExport={handleExport}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
lockPosition: true,
|
|
suppressNavigable: true,
|
|
suppressMovable: true,
|
|
filter: false,
|
|
sortable: false,
|
|
resizable: false,
|
|
suppressSizeToFit: true,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Vos devis
|
|
</h1>
|
|
<CreateDevisModal
|
|
handleCreate={handleCreate}
|
|
dueDateError={dueDateError}
|
|
setDueDateError={setDueDateError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="container mx-auto">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
{devisData?.length === 0 ? (
|
|
<EmptyState className="h-screen">
|
|
<EmptyStateIcon>
|
|
<NotebookPenIcon strokeWidth="1" />
|
|
</EmptyStateIcon>
|
|
<EmptyStateHeading>Aucun devis</EmptyStateHeading>
|
|
<EmptyStateDescription>
|
|
Créez un nouveau devis pour commencer.
|
|
</EmptyStateDescription>
|
|
<EmptyStateActions>
|
|
<CreateDevisModal
|
|
handleCreate={handleCreate}
|
|
dueDateError={dueDateError}
|
|
setDueDateError={setDueDateError}
|
|
/>
|
|
</EmptyStateActions>
|
|
</EmptyState>
|
|
) : (
|
|
<div className="ag-theme-alpine dark:ag-theme-alpine-dark w-full h-[400px]">
|
|
<AgGridReact<Devis>
|
|
rowData={devisData}
|
|
loading={isLoading}
|
|
gridOptions={{
|
|
rowHeight: 44,
|
|
theme: themeQuartz,
|
|
onRowDoubleClicked: (event) => {
|
|
if (event.data) {
|
|
setSelectedDevis(event.data);
|
|
setIsViewModalOpen(true);
|
|
}
|
|
},
|
|
suppressHorizontalScroll: true,
|
|
domLayout: "autoHeight",
|
|
loadingOverlayComponent: CustomLoadingOverlay,
|
|
loadingOverlayComponentParams: {
|
|
loadingMessage: "Chargement des devis...",
|
|
},
|
|
defaultColDef: {
|
|
sortable: true,
|
|
filter: false,
|
|
flex: 1,
|
|
minWidth: 100,
|
|
resizable: false,
|
|
},
|
|
}}
|
|
columnDefs={columnDefs}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<ViewDevisModal
|
|
selectedDevis={selectedDevis as Devis}
|
|
isOpen={isViewModalOpen}
|
|
setIsOpen={setIsViewModalOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|