365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
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; |