first commit
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { v4 } from "uuid";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "ikoncomponents";
|
||||
import ExpenseDataTable from "./expenseDataTable";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from "ikoncomponents";
|
||||
import { CountryDetails } from "@/app/utils/mapping/country";
|
||||
import { updateProductApi } from "@/app/utils/api/productOfProjectApi";
|
||||
import { useAppCache } from "@/app/utils/context/AppCacheContext";
|
||||
|
||||
// ─── Zod Schema ───────────────────────────────────────────────────────────────
|
||||
const expenseSchema = z.object({
|
||||
expenseName: z.string().min(1, "Expense name is required"),
|
||||
location: z.string().min(1, "Location is required"),
|
||||
currency: z.string().min(1, "Currency is required"),
|
||||
cost: z.number().positive("Cost must be greater than 0"),
|
||||
quantity: z.number().positive("Quantity must be greater than 0"),
|
||||
description: z.string().optional(),
|
||||
totalCost: z.number(),
|
||||
});
|
||||
|
||||
type ExpenseData = z.infer<typeof expenseSchema>;
|
||||
|
||||
interface ExpenseModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: (updatedData?: any) => void;
|
||||
productIdentifier: string;
|
||||
productData: any;
|
||||
setProductData: (data: any) => void;
|
||||
}
|
||||
|
||||
const ExpenseModal: React.FC<ExpenseModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
productIdentifier,
|
||||
productData,
|
||||
setProductData,
|
||||
}) => {
|
||||
const [expenseDetails, setExpenseDetails] = useState<Record<string, ExpenseData>>({});
|
||||
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// FX rates come from the shared app-wide cache (year -> currency -> rate).
|
||||
// Expenses carry no year, so use the current year's rates, falling back to
|
||||
// the most recent year available.
|
||||
const { fxRateMap } = useAppCache();
|
||||
const fxRates = useMemo<Map<string, number>>(() => {
|
||||
if (!fxRateMap || fxRateMap.size === 0) return new Map();
|
||||
const years = Array.from(fxRateMap.keys()).sort();
|
||||
const currentYear = String(new Date().getFullYear());
|
||||
const yearKey = fxRateMap.has(currentYear)
|
||||
? currentYear
|
||||
: years[years.length - 1];
|
||||
return fxRateMap.get(yearKey) ?? new Map();
|
||||
}, [fxRateMap]);
|
||||
|
||||
const form = useForm<ExpenseData>({
|
||||
resolver: zodResolver(expenseSchema),
|
||||
defaultValues: {
|
||||
expenseName: "",
|
||||
location: "",
|
||||
currency: "USD",
|
||||
cost: 0,
|
||||
quantity: 0,
|
||||
description: "",
|
||||
totalCost: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Load existing expense details
|
||||
useEffect(() => {
|
||||
if (!productData || !productData.expenseDetails) return;
|
||||
|
||||
const transformed: Record<string, ExpenseData> = {};
|
||||
Object.keys(productData.expenseDetails).forEach((uuid) => {
|
||||
const e = productData.expenseDetails[uuid];
|
||||
transformed[uuid] = {
|
||||
expenseName: e.expenseName || "",
|
||||
location: e.location || "",
|
||||
currency: e.currency || "USD",
|
||||
cost: Number(e.cost) || 0,
|
||||
quantity: Number(e.quantity) || 0,
|
||||
totalCost: Number(e.totalCost) || 0,
|
||||
description: e.remarks || "",
|
||||
};
|
||||
});
|
||||
|
||||
setExpenseDetails(transformed);
|
||||
}, [productData, isOpen]);
|
||||
|
||||
// Auto-calculate totalCost on watch
|
||||
useEffect(() => {
|
||||
const subscription = form.watch((value) => {
|
||||
const { cost, quantity, currency } = value;
|
||||
if (cost && quantity && currency) {
|
||||
const fx = fxRates.get(currency) || 1;
|
||||
const total = Number(cost) * Number(quantity) * fx;
|
||||
if (form.getValues("totalCost") !== total) {
|
||||
form.setValue("totalCost", total, { shouldValidate: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, fxRates]);
|
||||
|
||||
// ─── Add / Update (Zod validates here via handleSubmit) ───────────────────
|
||||
const handleAddOrUpdate = (data: ExpenseData) => {
|
||||
const fx = fxRates.get(data.currency) || 1;
|
||||
const totalCost = data.cost * data.quantity * fx;
|
||||
const expenseId = editingExpenseId || v4();
|
||||
|
||||
setExpenseDetails((prev) => ({
|
||||
...prev,
|
||||
[expenseId]: { ...data, totalCost },
|
||||
}));
|
||||
|
||||
setEditingExpenseId(null);
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const item = expenseDetails[id];
|
||||
if (item) {
|
||||
form.reset(item);
|
||||
setEditingExpenseId(id);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Done / Save (No Zod validation, bypasses form submit) ───────────────
|
||||
const handleSave = async () => {
|
||||
if (!productData) {
|
||||
console.error("No productData passed to modal");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const transformedExpenseDetails: Record<string, any> = {};
|
||||
Object.keys(expenseDetails).forEach((uuid) => {
|
||||
transformedExpenseDetails[uuid] = {
|
||||
expenseName: expenseDetails[uuid].expenseName,
|
||||
location: expenseDetails[uuid].location,
|
||||
currency: expenseDetails[uuid].currency,
|
||||
cost: Number(expenseDetails[uuid].cost),
|
||||
quantity: Number(expenseDetails[uuid].quantity),
|
||||
totalCost: Number(expenseDetails[uuid].totalCost),
|
||||
remarks: expenseDetails[uuid].description || "",
|
||||
};
|
||||
});
|
||||
|
||||
const newProductData = {
|
||||
...productData,
|
||||
productIdentifier,
|
||||
expenseDetails: transformedExpenseDetails,
|
||||
};
|
||||
|
||||
// console.log("Final Payload Before PUT:", newProductData);
|
||||
const response = await updateProductApi(newProductData);
|
||||
setProductData(newProductData);
|
||||
console.log("Backend response pf expense ",response)
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error saving expenses:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const expenseArray = Object.keys(expenseDetails).map((uuid) => ({
|
||||
uuid,
|
||||
...expenseDetails[uuid],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => open || onClose()}>
|
||||
<DialogContent className="sm:max-w-4xl!">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Expense</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleAddOrUpdate)}
|
||||
className="grid grid-cols-1 gap-3"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expenseName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expense Name*</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter expense name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location*</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CountryDetails.map((option: any) => (
|
||||
<SelectItem key={option.code} value={option.name}>
|
||||
{option.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency*</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["USD", "GBP", "INR", "QAR", "SAR", "AED"].map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cost*</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Quantity*</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totalCost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Total Cost*</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} placeholder="Enter description" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">
|
||||
{editingExpenseId ? "Update" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<ExpenseDataTable expenseDetails={expenseArray} onEdit={handleEdit} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Done"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseModal;
|
||||
Reference in New Issue
Block a user