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 { motion } from 'motion/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function Button({text, onClick}) {
|
||||
function Button({text, onClick, disabled}) {
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="px-12 py-2 text-lg text-white dark:text-primary-dark rounded-full bg-primary dark:bg-secondary-dark"
|
||||
whileHover={!disabled && { scale: 1.05 }}
|
||||
whileTap={!disabled && { scale: 0.95 }}
|
||||
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}
|
||||
disabled={disabled}
|
||||
>
|
||||
{text}
|
||||
</motion.button>
|
||||
@ -16,7 +18,8 @@ function Button({text, onClick}) {
|
||||
|
||||
Button.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired
|
||||
onClick: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { motion } from "motion/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
function FormField({ label, children, icon }) {
|
||||
return (
|
||||
@ -13,9 +14,10 @@ function FormField({ label, children, icon }) {
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@ -27,7 +29,7 @@ function FormField({ label, children, icon }) {
|
||||
FormField.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
icon: PropTypes.node,
|
||||
icon: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
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 LockerStatus from "./pages/LockerStatus.jsx";
|
||||
import KeySwap from "./pages/KeySwap.jsx";
|
||||
import ChargeManagement from "./pages/ChargeManagement.jsx";
|
||||
import ChargeEdit from "./pages/ChargeEdit.jsx";
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -49,6 +52,14 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: "operation/locker/key-swap",
|
||||
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,9 +1,9 @@
|
||||
function Placeholder() {
|
||||
return (
|
||||
<div className="text-2xl text-center h-max font-body text-primary dark:text-primary-dark">
|
||||
<h1>Placeholder</h1>
|
||||
</div >
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<p className="text-3xl text-primary">Placeholder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Placeholder;
|
||||
export default Placeholder;
|
||||
|
@ -18,5 +18,13 @@ export const lockerService = {
|
||||
|
||||
keySwap: async (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