Add ChargeManagement and ChargeEdit components; implement charge management functionality with form validation and routing updates
This commit is contained in:
parent
9d72dc6868
commit
44112f91bd
@ -1,13 +1,15 @@
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
function Button({text, onClick}) {
|
function Button({text, onClick, disabled}) {
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={!disabled && { scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={!disabled && { scale: 0.95 }}
|
||||||
className="px-12 py-2 text-lg text-white dark:text-primary-dark rounded-full bg-primary dark:bg-secondary-dark"
|
className={clsx("px-12 py-2 text-lg text-white dark:text-primary-dark rounded-full bg-primary dark:bg-secondary-dark", disabled && "bg-[#ccc] dark:bg-[#ccc]")}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@ -16,7 +18,8 @@ function Button({text, onClick}) {
|
|||||||
|
|
||||||
Button.propTypes = {
|
Button.propTypes = {
|
||||||
text: PropTypes.string.isRequired,
|
text: PropTypes.string.isRequired,
|
||||||
onClick: PropTypes.func.isRequired
|
onClick: PropTypes.func.isRequired,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Button;
|
export default Button;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
function FormField({ label, children, icon }) {
|
function FormField({ label, children, icon }) {
|
||||||
return (
|
return (
|
||||||
@ -13,9 +14,10 @@ function FormField({ label, children, icon }) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
className="bg-primary rounded-full p-2 text-white cursor-pointer"
|
className={clsx(icon.mode === "plain" ? "text-[#444]" : "bg-primary rounded-full p-2 text-white cursor-pointer")}
|
||||||
|
onClick={icon.onClick}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon.icon}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -27,7 +29,7 @@ function FormField({ label, children, icon }) {
|
|||||||
FormField.propTypes = {
|
FormField.propTypes = {
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
icon: PropTypes.node,
|
icon: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FormField;
|
export default FormField;
|
||||||
|
11
src/main.jsx
11
src/main.jsx
@ -12,6 +12,9 @@ import AccountCreation from "./pages/AccountCreation.jsx";
|
|||||||
import LockerMaintenance from "./pages/LockerMaintenance.jsx";
|
import LockerMaintenance from "./pages/LockerMaintenance.jsx";
|
||||||
import LockerStatus from "./pages/LockerStatus.jsx";
|
import LockerStatus from "./pages/LockerStatus.jsx";
|
||||||
import KeySwap from "./pages/KeySwap.jsx";
|
import KeySwap from "./pages/KeySwap.jsx";
|
||||||
|
import ChargeManagement from "./pages/ChargeManagement.jsx";
|
||||||
|
import ChargeEdit from "./pages/ChargeEdit.jsx";
|
||||||
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -49,6 +52,14 @@ const router = createBrowserRouter([
|
|||||||
{
|
{
|
||||||
path: "operation/locker/key-swap",
|
path: "operation/locker/key-swap",
|
||||||
element: <KeySwap />,
|
element: <KeySwap />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "operation/charge-management",
|
||||||
|
element: <ChargeManagement />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "operation/charge-management/change",
|
||||||
|
element: <ChargeEdit />,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
198
src/pages/ChargeEdit.jsx
Normal file
198
src/pages/ChargeEdit.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLoading } from "../hooks/useLoading";
|
||||||
|
import FormField from "../components/FormField";
|
||||||
|
import FormInput from "../components/FormInput";
|
||||||
|
import FormSelect from "../components/FormSelect";
|
||||||
|
import Button from "../components/Button";
|
||||||
|
import FormBox from "../components/FormBox";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useToast } from "../hooks/useToast";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { lockerService } from "../services/locker.service";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
function ChargeEdit() {
|
||||||
|
const [chargeDetails, setChargeDetails] = useState({
|
||||||
|
rentAmount: "",
|
||||||
|
rentAmountEdit: false,
|
||||||
|
penaltyAmount: "",
|
||||||
|
penaltyAmountEdit: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [notification, setNotification] = useState({
|
||||||
|
visible: false,
|
||||||
|
message: "",
|
||||||
|
type: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setIsLoading } = useLoading();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const showToast = useToast();
|
||||||
|
const location = useLocation();
|
||||||
|
const { productCode, interestCategory } = location.state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCharges = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await lockerService.getCharges(productCode, interestCategory);
|
||||||
|
if (response.status === 200) {
|
||||||
|
const { rent, penalty } = response.data;
|
||||||
|
setChargeDetails({
|
||||||
|
rentAmount: rent,
|
||||||
|
rentAmountEdit: false,
|
||||||
|
penaltyAmount: penalty,
|
||||||
|
penaltyAmountEdit: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNotification({
|
||||||
|
visible: true,
|
||||||
|
message: response.data.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setNotification({
|
||||||
|
visible: true,
|
||||||
|
message: error.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCharges();
|
||||||
|
}, [productCode, interestCategory, setIsLoading]);
|
||||||
|
|
||||||
|
const formFields = [
|
||||||
|
{
|
||||||
|
name: "rentAmount",
|
||||||
|
label: "Rent Amount",
|
||||||
|
type: "input",
|
||||||
|
subType: "number",
|
||||||
|
readOnly: !chargeDetails.rentAmountEdit,
|
||||||
|
icon: {
|
||||||
|
icon: <Pencil size={22}/>,
|
||||||
|
mode: "plain",
|
||||||
|
onClick: () => {setChargeDetails({...chargeDetails, rentAmountEdit: true})},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "penaltyAmount",
|
||||||
|
label: "Penalty Amount",
|
||||||
|
type: "input",
|
||||||
|
subType: "number",
|
||||||
|
readOnly: !chargeDetails.penaltyAmountEdit,
|
||||||
|
icon: {
|
||||||
|
icon: <Pencil size={22}/>,
|
||||||
|
mode: "plain",
|
||||||
|
onClick: () => {setChargeDetails({...chargeDetails, penaltyAmountEdit: true})},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if(!chargeDetails.rentAmountEdit && !chargeDetails.penaltyAmountEdit) {
|
||||||
|
showToast("No changes made", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await lockerService.updateCharges(
|
||||||
|
productCode,
|
||||||
|
interestCategory,
|
||||||
|
chargeDetails.rentAmount,
|
||||||
|
chargeDetails.penaltyAmount
|
||||||
|
);
|
||||||
|
if (response.status === 200) {
|
||||||
|
setNotification({
|
||||||
|
visible: true,
|
||||||
|
message: response.data.message,
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setNotification({
|
||||||
|
visible: true,
|
||||||
|
message: response.data.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setNotification({
|
||||||
|
visible: true,
|
||||||
|
message: error.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = (field) => {
|
||||||
|
const commonProps = {
|
||||||
|
value: chargeDetails[field.name],
|
||||||
|
onChange: (e) => {
|
||||||
|
setChargeDetails({
|
||||||
|
...chargeDetails,
|
||||||
|
[field.name]: e.target.value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readOnly: field.readOnly,
|
||||||
|
className: field.readOnly ? "bg-grey/[0.3]" : "",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<FormField key={field.name} label={field.label} icon={field.icon}>
|
||||||
|
{field.type === "input" ? (
|
||||||
|
<FormInput
|
||||||
|
{...commonProps}
|
||||||
|
type={field.subType}
|
||||||
|
readOnly={field.readOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormSelect {...commonProps} options={field.options} />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{notification.visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={clsx(
|
||||||
|
"p-4 mb-8 font-body text-center text-xl rounded-2xl flex items-center justify-center gap-2",
|
||||||
|
notification.type === "error"
|
||||||
|
? "bg-error-surface text-error"
|
||||||
|
: "bg-success-surface text-success"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="relative">
|
||||||
|
{notification.type === "success" && (
|
||||||
|
<div className="absolute inset-0 bg-[#fff]/50 z-10 rounded-3xl" />
|
||||||
|
)}
|
||||||
|
<FormBox title={t("chargeEdit")}>
|
||||||
|
<div className="p-2 pt-7 flex flex-col gap-4">
|
||||||
|
{formFields.map(renderField)}
|
||||||
|
</div>
|
||||||
|
<Button text={t("submit")} onClick={handleSubmit} disabled={!chargeDetails.rentAmountEdit && !chargeDetails.penaltyAmountEdit}/>
|
||||||
|
</FormBox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChargeEdit;
|
114
src/pages/ChargeManagement.jsx
Normal file
114
src/pages/ChargeManagement.jsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import FormField from "../components/FormField";
|
||||||
|
import FormInput from "../components/FormInput";
|
||||||
|
import FormSelect from "../components/FormSelect";
|
||||||
|
import Button from "../components/Button";
|
||||||
|
import FormBox from "../components/FormBox";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useToast } from "../hooks/useToast";
|
||||||
|
|
||||||
|
function ChargeManagement() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const showToast = useToast();
|
||||||
|
|
||||||
|
const [productDetails, setProductDetails] = useState({
|
||||||
|
productCode: "",
|
||||||
|
interestCategory: "",
|
||||||
|
productCodeValid: true,
|
||||||
|
interestCategoryValid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formFields = [
|
||||||
|
{
|
||||||
|
name: "productCode",
|
||||||
|
label: t("productCode"),
|
||||||
|
type: "input",
|
||||||
|
subType: "number",
|
||||||
|
maxLength: 4,
|
||||||
|
validate: (value) => /^[0-9]{4}/.test(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "interestCategory",
|
||||||
|
label: t("interestCategory"),
|
||||||
|
type: "input",
|
||||||
|
subType: "number",
|
||||||
|
maxLength: 4,
|
||||||
|
validate: (value) => /^[0-9]{4}/.test(value),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newProductDetails = { ...productDetails };
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
formFields.forEach((field) => {
|
||||||
|
if (!field.validate(newProductDetails[field.name])) {
|
||||||
|
isValid = false;
|
||||||
|
newProductDetails[`${field.name}Valid`] = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
setProductDetails(newProductDetails);
|
||||||
|
showToast(t("highlightedFieldsInvalid"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("change", {
|
||||||
|
state: {
|
||||||
|
productCode: productDetails.productCode,
|
||||||
|
interestCategory: productDetails.interestCategory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = (field) => {
|
||||||
|
const commonProps = {
|
||||||
|
value: productDetails[field.name],
|
||||||
|
onChange: (e) => {
|
||||||
|
const newLockerDetails = { ...productDetails };
|
||||||
|
if (field.subType === "number") {
|
||||||
|
e.target.value = e.target.value.replace(/\D/g, "");
|
||||||
|
if (e.target.value.length > field.maxLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newLockerDetails[field.name] = e.target.value;
|
||||||
|
newLockerDetails[`${field.name}Valid`] = true;
|
||||||
|
setProductDetails(newLockerDetails);
|
||||||
|
},
|
||||||
|
maxLength: field.maxLength,
|
||||||
|
className: clsx(!productDetails[`${field.name}Valid`] && "border-error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField key={field.name} label={field.label} icon={field.icon}>
|
||||||
|
{field.type === "input" ? (
|
||||||
|
<FormInput
|
||||||
|
{...commonProps}
|
||||||
|
type={field.subType}
|
||||||
|
readOnly={field.readOnly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormSelect {...commonProps} options={field.options} />
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormBox title={t("lockerStatus")}>
|
||||||
|
<div className="p-2 pt-7 flex flex-col gap-4">
|
||||||
|
{formFields.map(renderField)}
|
||||||
|
</div>
|
||||||
|
<Button text={t("submit")} onClick={handleSubmit} />
|
||||||
|
</FormBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChargeManagement;
|
@ -1,8 +1,8 @@
|
|||||||
function Placeholder() {
|
function Placeholder() {
|
||||||
return (
|
return (
|
||||||
<div className="text-2xl text-center h-max font-body text-primary dark:text-primary-dark">
|
<div className="flex justify-center items-center h-full">
|
||||||
<h1>Placeholder</h1>
|
<p className="text-3xl text-primary">Placeholder</p>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,5 +18,13 @@ export const lockerService = {
|
|||||||
|
|
||||||
keySwap: async (cabinetId, lockerId, reason, oldKey, newKey) => {
|
keySwap: async (cabinetId, lockerId, reason, oldKey, newKey) => {
|
||||||
return api.patch(`/locker/key`, { cabinetId, lockerId, reason, oldKey, newKey });
|
return api.patch(`/locker/key`, { cabinetId, lockerId, reason, oldKey, newKey });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCharges: async (productCode, interestCategory, rent, penalty) => {
|
||||||
|
return api.patch(`/charge/${productCode}${interestCategory}`, { rent, penalty });
|
||||||
|
},
|
||||||
|
|
||||||
|
getCharges: async (productCode, interestCategory) => {
|
||||||
|
return api.get(`/charge/${productCode}${interestCategory}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
Loading…
x
Reference in New Issue
Block a user