added challan

This commit is contained in:
pramod.mahajan 2025-11-27 21:25:23 +05:30
parent 1b3e090211
commit b8d3e58428
9 changed files with 582 additions and 52 deletions

View File

@ -234,7 +234,9 @@ font-weight: normal;
.h-min { height: min-content; }
.h-max { height: max-content; }
.vh-50 {
height: 50vh !important;
}

View File

@ -18,9 +18,7 @@ const Filelist = ({ files, removeFile, expenseToEdit,sm=6,md=4 }) => {
{/* File icon and info */}
<div className="d-flex align-items-center flex-grow-1 gap-2 overflow-hidden">
<i
className={`bx ${getIconByFileType(
file?.contentType
)} fs-3 `}
className={`bx ${getIconByFileType(file?.contentType)} fs-3 `}
style={{ minWidth: "30px" }}
></i>
@ -49,7 +47,6 @@ const Filelist = ({ files, removeFile, expenseToEdit,sm=6,md=4 }) => {
</div>
))}
</div>
);
};
@ -62,9 +59,7 @@ export const FilelistView = ({ files, viewFile }) => {
<div className="row align-items-center">
{/* File icon and info */}
<div className="col-12 d-flex align-items-center gap-2">
<i
className={`bx ${getIconByFileType(file?.fileName)} fs-3`}
></i>
<i className={`bx ${getIconByFileType(file?.fileName)} fs-3`}></i>
<div
className="d-flex flex-column text-truncate"
@ -93,3 +88,44 @@ export const FilelistView = ({ files, viewFile }) => {
</div>
);
};
export const FileView = ({ file, viewFile }) => {
if (!file) {
return (
<div className="d-flex justify-content-center align-items-center py-4">
<p className="mb-0 text-muted small">No file uploaded</p>
</div>
);
}
return (
<div className=" bg-white ">
<div className="row align-items-center">
{/* File icon and info */}
<div className="col-12 d-flex align-items-center gap-2">
<i className={`bx ${getIconByFileType(file?.fileName)} fs-4`}></i>
<div
className="d-flex flex-column text-truncate"
onClick={(e) => {
e.preventDefault();
viewFile({
IsOpen: true,
Image: file.preSignedUrl,
});
}}
>
<span className="text-muted small text-truncate">
{file.fileName}
</span>
<span className="text-body-secondary small">
<Tooltip text={"Click on file"}>
{" "}
{file.fileSize ? formatFileSize(file.fileSize) : ""}
</Tooltip>
</span>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,111 @@
import { useRef } from "react";
import Label from "./Label";
import Tooltip from "./Tooltip";
import { formatFileSize, getIconByFileType } from "../../utils/appUtils";
const SingleFileUploader = ({
label = "Upload Document",
required = false,
value,
onChange,
onRemove,
disabled,
error,
accept = ".pdf,.jpg,.jpeg,.png",
maxSizeMB = 25,
hint = "(PDF, JPG, PNG, max 5MB)",
}) => {
const inputRef = useRef(null);
const handleFileSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate size
if (file.size > maxSizeMB * 1024 * 1024) {
alert(`File size cannot exceed ${maxSizeMB}MB`);
e.target.value = "";
return;
}
// Convert to base64
const base64Data = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.split(",")[1]);
reader.onerror = (err) => reject(err);
});
const attachmentObj = {
fileName: file.name,
base64Data,
invoiceAttachmentTypeId: "", // set dynamically if needed
contentType: file.type,
fileSize: file.size,
description: "",
isActive: true,
};
onChange(attachmentObj);
e.target.value = "";
};
return (
<div className="col-md-12">
<Label className="form-label" required={required}>
{label}
</Label>
<div
className="border border-secondary border-dashed rounded p-4 text-center bg-textMuted position-relative"
style={{ cursor: "pointer" }}
onClick={() => inputRef.current.click()}
>
<i className="bx bx-cloud-upload d-block bx-lg"></i>
<span className="text-muted d-block">
Click to select or click here to browse
</span>
<small className="text-muted">{hint}</small>
<input
type="file"
ref={inputRef}
accept={accept}
style={{ display: "none" }}
onChange={handleFileSelect}
disabled={disabled}
/>
</div>
{error && <small className="danger-text">{error}</small>}
{value && (
<div className="mt-3">
<div className="d-flex align-items-center justify-content-between bg-white border rounded p-2">
<div className="d-flex align-items-center gap-2">
<i
className={`bx ${getIconByFileType(value.contentType)} fs-3`}
></i>
<div className="d-flex flex-column text-truncate">
<span className="fw-semibold small text-truncate">
{value.fileName}
</span>
<small className="text-muted">
{value.fileSize ? formatFileSize(value.fileSize) : ""}
</small>
</div>
</div>
<i
className="bx bx-trash text-danger fs-5 cursor-pointer"
onClick={onRemove}
></i>
</div>
</div>
)}
</div>
);
};
export default SingleFileUploader;

View File

@ -0,0 +1,68 @@
import React from "react";
import { useDeliverChallane } from "../../hooks/usePurchase";
import { SpinnerLoader } from "../common/Loader";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import { FileView } from "../Expenses/Filelist";
const DeliverChallanList = ({ purchaseId }) => {
const { data, isLoading, isError, error } = useDeliverChallane(purchaseId);
if (isLoading) {
return (
<div className="d-flex justify-content-center align-items-center text-center vh-50">
<SpinnerLoader />
</div>
);
}
if (isError) {
return (
<div className="py-3">
<Error error={error} />
</div>
);
}
if (!isLoading && data.length === 0)
return (
<div className="d-flex justify-content-center align-items-center text-center vh-50">
<p className="mb-0">Not Yet</p>
</div>
);
return (
<div className="list-group list-group-flush text-start">
{data.map((item) => (
<div
key={item.id}
className="list-group-item d-flex justify-content-between align-items-start flex-wrap px-3 w-full"
>
{/* LEFT SIDE */}
<div className=" pe-3 text-break w-full">
<div className="d-flex justify-content-between">
<p className="mb-1 fw-semibold">{item.deliveryChallanNumber}</p>
<small className="text-muted text-end">
{formatUTCToLocalTime(item.deliveryChallanDate, true)}
</small>
</div>
<div className="mb-1 small text-muted text-break">
<span className="fw-semibold me-1">Invoice:</span>
{item.purchaseInvoice?.title} (
{item.purchaseInvoice?.purchaseInvoiceUId})
</div>
<p className="mb-1 text-break">
<span className="fw-semibold">Description:</span>{" "}
{item.description || "-"}
</p>
{item.attachment?.preSignedUrl && (
<FileView file={item.attachment} />
)}
</div>
</div>
))}
</div>
);
};
export default DeliverChallanList;

View File

@ -0,0 +1,205 @@
import React, { useState } from "react";
import {
useAddDeliverChallan,
useDeliverChallane,
} from "../../hooks/usePurchase";
import { SpinnerLoader } from "../common/Loader";
import { formatUTCToLocalTime } from "../../utils/dateUtils";
import DeliverChallanList from "./DeliverChallanList";
import { AppFormController, useAppForm } from "../../hooks/appHooks/useAppForm";
import { zodResolver } from "@hookform/resolvers/zod";
import {
DeliveryChallanDefaultValue,
DeliveryChallanSchema,
} from "./PurchaseSchema";
import Label from "../common/Label";
import DatePicker from "../common/DatePicker";
import { useInvoiceAttachmentTypes } from "../../hooks/masterHook/useMaster";
import SelectField from "../common/Forms/SelectField";
import Filelist from "../Expenses/Filelist";
import SingleFileUploader from "../common/SigleFileUploader";
import { localToUtc } from "../../utils/appUtils";
const DeliveryChallane = ({ purchaseId }) => {
const [file, setFile] = useState(null);
const {
register,
control,
handleSubmit,
setValue,
trigger,
watch,
reset,
formState: { errors },
} = useAppForm({
resolver: zodResolver(DeliveryChallanSchema),
defaultValues: DeliveryChallanDefaultValue,
});
const { data, isLoading } = useInvoiceAttachmentTypes();
const { mutate: AddChallan, isPending } = useAddDeliverChallan(() => {
setFile(null);
setValue("attachment", null, { shouldValidate: false });
reset({
...DeliveryChallanDefaultValue,
attachment: null,
});
});
const onSubmit = async (formData) => {
const valid = await trigger();
if (!valid) return;
const { invoiceAttachmentTypeId, ...rest } = formData;
const payload = {
...rest,
attachment: formData.attachment
? {
...formData.attachment,
invoiceAttachmentTypeId,
}
: null,
purchaseInvoiceId: purchaseId,
deliveryChallanDate: formData.deliveryChallanDate
? localToUtc(formData.deliveryChallanDate)
: null,
};
AddChallan(payload);
};
const isUploaded = watch("attachment");
const isDocumentType = watch("invoiceAttachmentTypeId");
return (
<div className="row px-3 px-sm-1">
<div className="fw-semibold mb-3 fs-5">Delivery Challans</div>
<div className="col-md-6">
<form
onSubmit={handleSubmit(onSubmit)}
className="row g-3 text-start"
noValidate
>
{/* Challan Number */}
<div className="col-md-6">
<Label required>Delivery Challan No</Label>
<input
type="text"
className="form-control form-control-sm"
{...register("deliveryChallanNumber")}
/>
{errors?.deliveryChallanNumber && (
<small className="danger-text">
{errors.deliveryChallanNumber.message}
</small>
)}
</div>
<div className="col-md-6">
<Label required>Delivery Date</Label>
<DatePicker
name="deliveryChallanDate"
control={control}
className="w-100"
/>
{errors?.deliveryChallanDate && (
<small className="danger-text">
{errors.deliveryChallanDate.message}
</small>
)}
</div>
<div className="col-12">
<Label required>Description</Label>
<textarea
className="form-control form-control-sm"
rows="3"
{...register("description")}
/>
{errors?.description && (
<small className="danger-text">
{errors.description.message}
</small>
)}
</div>
<div className="col-12">
<AppFormController
name="invoiceAttachmentTypeId"
control={control}
render={({ field }) => (
<SelectField
label="Select Status"
options={data ?? []}
placeholder="Choose a Status"
labelKeyKey="name"
valueKeyKey="id"
value={field.value}
onChange={field.onChange}
isLoading={isLoading}
className="m-0"
/>
)}
/>
{errors?.invoiceAttachmentTypeId && (
<small className="danger-text">
{errors.invoiceAttachmentTypeId.message}
</small>
)}
</div>
<SingleFileUploader
label="Upload Bill"
required
value={file}
onChange={(selectedFile) => {
setFile(selectedFile);
setValue("attachment", selectedFile, { shouldValidate: true });
}}
disabled={!isDocumentType}
onRemove={() => {
setFile(null);
setValue("attachment", null, { shouldValidate: true });
}}
error={errors?.attachment?.message}
/>
<div className="col-12 text-end">
<button type="submit" className="btn btn-sm btn-primary px-4">
{isPending ? "Please Wait..." : "Submit"}
</button>
</div>
</form>
{!isUploaded && (
<div className="col-12 my-1">
<div className="d-flex align-items-center gap-2 p-3 rounded bg-warning bg-opacity-10 border border-warning-subtle text-start align-item-start">
<i className="bx bx-info-circle text-warning fs-5"></i>
<p className="mb-0 small">
If want upload document, Please select a document type before
uploading the document.
</p>
</div>
</div>
)}
</div>
<div className="col-md-6 text-start">
<div className="d-flex justiffy-content-start align-items-center gap-1">
<i className="bx bx-history bx-xs"></i>{" "}
<p className="fw-medium mb-0">History</p>
</div>
<div className="overflow-auto " style={{ maxHeight: "420px" }}>
<DeliverChallanList purchaseId={purchaseId} />
</div>
</div>
</div>
);
};
export default DeliveryChallane;

View File

@ -8,7 +8,8 @@ import { useDebounce } from "../../utils/appUtils";
import { usePurchaseContext } from "../../pages/purchase/PurchasePage";
const PurchaseList = ({ searchString }) => {
const { setViewPurchase, setManagePurchase } = usePurchaseContext();
const { setViewPurchase, setManagePurchase, setChallan } =
usePurchaseContext();
const [currentPage, setCurrentPage] = useState(1);
const debounceSearch = useDebounce(searchString, 300);
const { data, isLoading } = usePurchasesList(
@ -120,7 +121,15 @@ const PurchaseList = ({ searchString }) => {
</a>
</li>
<li>
<a className="dropdown-item cursor-pointer">
<a
className="dropdown-item cursor-pointer"
onClick={() =>
setChallan({
isOpen: true,
purchaseId: item.id,
})
}
>
<i className="bx bx-trash me-2"></i>
<span className="align-left">Delete</span>
</a>

View File

@ -144,3 +144,49 @@ export const getStepFields = (stepIndex) => {
return stepFieldMap[stepIndex] || [];
};
export const SingleAttachmentSchema = z.object({
fileName: z.string().min(1, { message: "File name is required" }),
base64Data: z.string().min(1, { message: "File data is required" }),
invoiceAttachmentTypeId: z.string().nullable(),
contentType: z
.string()
.min(1, { message: "MIME type is required" })
.refine(
(val) =>
["application/pdf", "image/jpeg", "image/jpg", "image/png"].includes(
val
),
{
message: "File type must be PDF, JPG, JPEG or PNG",
}
),
fileSize: z
.number()
.int()
.nonnegative("fileSize must be ≥ 0")
.max(5 * 1024 * 1024, "File size must be ≤ 5MB"),
description: z.string().optional().default(""),
isActive: z.boolean(),
});
export const DeliveryChallanSchema = z.object({
deliveryChallanNumber: z
.string()
.min(1, { message: "Challan Number required" }),
invoiceAttachmentTypeId: z.string().nullable(),
deliveryChallanDate: z.string().min(1, { message: "Deliver date required" }),
description: z.string().min(1, { message: "Description required" }),
attachment: z.object(SingleAttachmentSchema.shape).nullable(),
});
export const DeliveryChallanDefaultValue = {
deliveryChallanNumber: "",
deliveryChallanDate: "",
description: "",
attachment: null,
invoiceAttachmentTypeId: null,
};

View File

@ -31,6 +31,19 @@ export const usePurchasesList = (
});
};
export const useDeliverChallane = (purchaseInvoiceId) => {
return useQuery({
queryKey: ["deliverliy_challane", purchaseInvoiceId],
queryFn: async () => {
const resp = await PurchaseRepository.GetDeliveryChallan(
purchaseInvoiceId
);
return resp.data;
},
enabled: !!purchaseInvoiceId,
});
};
export const usePurchase = (id) => {
return useQuery({
queryKey: ["purchase", id],
@ -67,7 +80,8 @@ export const useUpdatePurchaseInvoice = (onSuccessCallback) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({purchaseId,payload}) => PurchaseRepository.UpdatePurchase(purchaseId, payload),
mutationFn: async ({ purchaseId, payload }) =>
PurchaseRepository.UpdatePurchase(purchaseId, payload),
onSuccess: (data, variables) => {
showToast("Purchase Invoice Updated successfully", "success");
if (onSuccessCallback) onSuccessCallback();
@ -82,3 +96,24 @@ export const useUpdatePurchaseInvoice = (onSuccessCallback) => {
},
});
};
export const useAddDeliverChallan = (onSuccessCallback) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload) =>
PurchaseRepository.addDelievryChallan(payload),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ["deliverliy_challane"] });
showToast("Challan added successfully", "success");
if (onSuccessCallback) onSuccessCallback();
},
onError: (error) => {
showToast(
error?.response?.data?.message ||
error.message ||
"Failed to create invoice",
"error"
);
},
});
};

View File

@ -5,6 +5,7 @@ import GlobalModel from "../../components/common/GlobalModel";
import ManagePurchase from "../../components/purchase/ManagePurchase";
import PurchaseList from "../../components/purchase/PurchaseList";
import ViewPurchase from "../../components/purchase/ViewPurchase";
import DeliveryChallane from "../../components/purchase/DeliveryChallane";
export const PurchaseContext = createContext();
export const usePurchaseContext = () => {
@ -18,6 +19,10 @@ export const usePurchaseContext = () => {
};
const PurchasePage = () => {
const [searchText, setSearchText] = useState("");
const [addChallan, setChallan] = useState({
isOpen: false,
purchaseId: null,
});
const [managePurchase, setManagePurchase] = useState({
isOpen: false,
purchaseId: null,
@ -30,6 +35,7 @@ const PurchasePage = () => {
const contextValue = {
setViewPurchase,
setManagePurchase,
setChallan,
};
return (
<PurchaseContext.Provider value={contextValue}>
@ -107,6 +113,18 @@ const PurchasePage = () => {
<ViewPurchase purchaseId={viewPurchaseState.purchaseId} />
</GlobalModel>
)}
{addChallan.isOpen && (
<GlobalModel size="xl"
isOpen={addChallan.isOpen}
closeModal={() => setChallan({ isOpen: false, purchaseId: null })}
>
<DeliveryChallane
purchaseId={addChallan.purchaseId}
onClose={() => setChallan({ isOpen: false, purchaseId: null })}
/>
</GlobalModel>
)}
</div>
</PurchaseContext.Provider>
);