285 lines
9.8 KiB
TypeScript
285 lines
9.8 KiB
TypeScript
import {
|
|
decryptDataWithStoredPassword,
|
|
encryptDataWithStoredPassword,
|
|
getEncryptionPassword,
|
|
MakeNewDate,
|
|
} from "../helpers.ts";
|
|
import { Obligation } from "../services/financhalServices.ts";
|
|
import { useEffect, useRef, useState } from "preact/hooks";
|
|
import { PopAddObligationDialog } from "./dialogs/PopAddObligationDialog.tsx";
|
|
|
|
interface Props {
|
|
obligations: Obligation[];
|
|
userId?: string;
|
|
openModal?: boolean;
|
|
}
|
|
|
|
export default function ObligationList(
|
|
{ obligations, userId, openModal = false }: Props,
|
|
) {
|
|
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
|
|
const [_popAddObligation, popupAddObligationDialog] = PopAddObligationDialog({
|
|
userId,
|
|
defaultOpen: openModal,
|
|
});
|
|
const [processedObligations, setProcessedObligations] = useState<
|
|
Obligation[]
|
|
>(obligations);
|
|
const [_isProcessing, setIsProcessing] = useState(false);
|
|
const [_error, setError] = useState<string | null>(null);
|
|
const [isOpeningModal, setIsOpeningModal] = useState(openModal);
|
|
const processingRef = useRef(false);
|
|
|
|
// Process obligations on component mount and when obligations or userId change
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
const lockRoute = localStorage.getItem("lock-route");
|
|
const url = new URL(globalThis.location.href);
|
|
const currentRoute = url.pathname + url.search;
|
|
|
|
if (lockRoute && lockRoute === currentRoute) {
|
|
localStorage.removeItem("lock-route");
|
|
}
|
|
}, 100);
|
|
|
|
// Prevent concurrent processing
|
|
if (processingRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (!userId) {
|
|
setProcessedObligations(obligations);
|
|
setError(null);
|
|
return;
|
|
}
|
|
|
|
const processObligations = async () => {
|
|
processingRef.current = true;
|
|
setIsProcessing(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const encryptionPassword = getEncryptionPassword(userId);
|
|
|
|
if (!encryptionPassword) {
|
|
// No encryption key available, use obligations as-is
|
|
setProcessedObligations(obligations);
|
|
return;
|
|
}
|
|
|
|
const updatedObligations: Obligation[] = [];
|
|
let hasEncryptionUpdates = false;
|
|
|
|
for (const obligation of obligations) {
|
|
const processedObligation = { ...obligation };
|
|
|
|
if (obligation.encrypted_data) {
|
|
// Try to decrypt existing encrypted data
|
|
try {
|
|
const decryptedData = await decryptDataWithStoredPassword(
|
|
userId,
|
|
obligation.encrypted_data,
|
|
);
|
|
if (decryptedData) {
|
|
const parsed = JSON.parse(decryptedData);
|
|
if (parsed.amount !== undefined) {
|
|
processedObligation.amount = parsed.amount;
|
|
}
|
|
if (parsed.description !== undefined) {
|
|
processedObligation.description = parsed.description;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to decrypt obligation data:", error);
|
|
setError("Failed to decrypt some obligation data");
|
|
}
|
|
} else if (
|
|
obligation.amount !== null || obligation.description !== null
|
|
) {
|
|
// No encrypted data but has plaintext data - encrypt it and clear plaintext
|
|
try {
|
|
const dataToEncrypt = {
|
|
amount: obligation.amount,
|
|
description: obligation.description,
|
|
};
|
|
|
|
const encryptedData = await encryptDataWithStoredPassword(
|
|
userId,
|
|
JSON.stringify(dataToEncrypt),
|
|
);
|
|
|
|
if (encryptedData) {
|
|
// Update the obligation in the database
|
|
const response = await fetch("/api/update/obligation", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
obligation_id: obligation.id,
|
|
encrypted_data: encryptedData,
|
|
description: null, // Clear plaintext description
|
|
amount: null, // Clear plaintext amount
|
|
}),
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Update local state
|
|
processedObligation.encrypted_data = encryptedData;
|
|
processedObligation.description = obligation.description; // Keep for display
|
|
processedObligation.amount = obligation.amount; // Keep for display
|
|
hasEncryptionUpdates = true;
|
|
} else {
|
|
console.error("Failed to update obligation encryption");
|
|
setError("Failed to encrypt some obligation data");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to encrypt obligation data:", error);
|
|
setError("Failed to encrypt some obligation data");
|
|
}
|
|
}
|
|
|
|
updatedObligations.push(processedObligation);
|
|
}
|
|
|
|
setProcessedObligations(updatedObligations);
|
|
|
|
if (hasEncryptionUpdates) {
|
|
console.log("Successfully encrypted plaintext obligation data");
|
|
}
|
|
} catch (error) {
|
|
console.error("Unexpected error during obligation processing:", error);
|
|
setError("An unexpected error occurred while processing obligations");
|
|
setProcessedObligations(obligations); // Fallback to original data
|
|
} finally {
|
|
setIsProcessing(false);
|
|
processingRef.current = false;
|
|
}
|
|
};
|
|
|
|
processObligations();
|
|
|
|
// Cleanup function to handle component unmount
|
|
return () => {
|
|
processingRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{popupAddObligationDialog}
|
|
<div class="flex flex-col mt-14 gap-6">
|
|
<div class="list-header">
|
|
<h2 class="theme-text list-title">
|
|
Your Obligations
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
class="list-action-button"
|
|
onClick={() => {
|
|
if (isOpeningModal) return;
|
|
|
|
localStorage.setItem(
|
|
"lock-route",
|
|
"/calendar/budget-obligations?modal=on",
|
|
);
|
|
setIsOpeningModal(true);
|
|
globalThis.location.href =
|
|
"/calendar/budget-obligations?modal=on";
|
|
}}
|
|
disabled={isOpeningModal}
|
|
>
|
|
+ Add Obligation
|
|
</button>
|
|
</div>
|
|
<ul class="space-y-2">
|
|
{processedObligations.map((ob) => (
|
|
<li
|
|
key={ob.id}
|
|
class="border rounded p-4 shadow relative theme-container"
|
|
>
|
|
<form
|
|
method="POST"
|
|
action="/api/delete/obligation"
|
|
class="absolute top-1/2 right-0 transform -translate-y-1/2 inline-form"
|
|
onSubmit={(e) => {
|
|
// Set sender input to current page
|
|
const form = e.currentTarget;
|
|
form.sender.value = location.pathname;
|
|
|
|
// Add confirmation for delete
|
|
if (
|
|
!confirm(
|
|
`Are you sure you want to delete "${
|
|
ob.description || "this obligation"
|
|
}"?`,
|
|
)
|
|
) {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
}}
|
|
>
|
|
<input type="hidden" name="obligation_id" value={ob.id} />
|
|
<input
|
|
type="hidden"
|
|
name="sender"
|
|
value={typeof location !== "undefined"
|
|
? location.pathname
|
|
: ""}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
aria-label="Delete obligation"
|
|
class="theme-icon-button button-reset hover:text-red-500 transition-colors"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
<div class="font-semibold text-lg theme-text">
|
|
{ob.description || ""}
|
|
</div>
|
|
<div class="text-sm theme-calendar-list-frequency">
|
|
Amount: ${ob.amount?.toFixed(2) || "0.00"}
|
|
</div>
|
|
<div class="text-sm theme-calendar-list-frequency">
|
|
Frequency: {ob.frequency}
|
|
</div>
|
|
<div class="text-sm theme-calendar-list-frequency">
|
|
Due: {ob.due_date
|
|
? MakeNewDate(ob.due_date).toISOString().split("T")[0]
|
|
: ""}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div aria-hidden="true" class="h-28"></div>
|
|
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
|
|
<a
|
|
href={`/calendar/financial-calendar/${new Date().getFullYear()}/${
|
|
(new Date().getMonth() + 1).toString().padStart(2, "0")
|
|
}?`}
|
|
class="w-11/12 max-w-md bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center"
|
|
>
|
|
Back to Financial Calendar
|
|
</a>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|