integratedhub/islands/IncomeList.tsx
2026-02-14 02:20:19 +00:00

247 lines
8.1 KiB
TypeScript

import {
decryptDataWithStoredPassword,
encryptDataWithStoredPassword,
getEncryptionPassword,
MakeNewDate,
} from "../helpers.ts";
import { Income } from "../services/financhalServices.ts";
import { useEffect, useState } from "preact/hooks";
import { PopAddIncomeDialog } from "./dialogs/PopAddIncomeDialog.tsx";
interface Props {
incomes: Income[];
userId?: string;
openModal?: boolean;
}
export default function IncomeList(
{ incomes, userId, openModal = false }: Props,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [_popAddIncome, popupAddIncomeDialog] = PopAddIncomeDialog({
userId,
defaultOpen: openModal,
});
const [processedIncomes, setProcessedIncomes] = useState<Income[]>(incomes);
const [_isProcessing, setIsProcessing] = useState(false);
const [isOpeningModal, setIsOpeningModal] = useState(openModal);
// Process incomes on component mount and when incomes 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);
if (!userId) {
setProcessedIncomes(incomes);
return;
}
const processIncomes = async () => {
setIsProcessing(true);
const encryptionPassword = getEncryptionPassword(userId);
if (!encryptionPassword) {
// No encryption key available, use incomes as-is
setProcessedIncomes(incomes);
setIsProcessing(false);
return;
}
const updatedIncomes: Income[] = [];
for (const income of incomes) {
const processedIncome = { ...income };
if (income.encrypted_data) {
// Try to decrypt existing encrypted data
try {
const decryptedData = await decryptDataWithStoredPassword(
userId,
income.encrypted_data,
);
if (decryptedData) {
const parsed = JSON.parse(decryptedData);
if (parsed.amount !== undefined) {
processedIncome.amount = parsed.amount;
}
if (parsed.source !== undefined) {
processedIncome.source = parsed.source;
}
}
} catch (error) {
console.error("Failed to decrypt income data:", error);
}
} else if (income.amount !== null || income.source !== null) {
// No encrypted data but has plaintext data - encrypt it and clear plaintext
try {
const dataToEncrypt = {
amount: income.amount,
source: income.source,
};
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
// Update the income in the database
const response = await fetch("/api/update/income", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
income_id: income.id,
encrypted_data: encryptedData,
source: null, // Clear plaintext source
amount: null, // Clear plaintext amount
}),
});
if (response.ok) {
// Update local state
processedIncome.encrypted_data = encryptedData;
processedIncome.source = income.source; // Keep for display
processedIncome.amount = income.amount; // Keep for display
}
}
} catch (error) {
console.error("Failed to encrypt income data:", error);
}
}
updatedIncomes.push(processedIncome);
}
setProcessedIncomes(updatedIncomes);
setIsProcessing(false);
};
processIncomes();
}, []);
return (
<>
{popupAddIncomeDialog}
<div class="flex flex-col mt-14 gap-6">
<div class="list-header">
<h2 class="theme-text list-title">
Your Incomes
</h2>
<button
type="button"
class="list-action-button"
onClick={() => {
if (isOpeningModal) return;
localStorage.setItem(
"lock-route",
"/calendar/every-income?modal=on",
);
setIsOpeningModal(true);
globalThis.location.href = "/calendar/every-income?modal=on";
}}
disabled={isOpeningModal}
>
+ Add Income
</button>
</div>
<ul class="space-y-2">
{processedIncomes.map((inc) => (
<li
key={inc.id}
class="border rounded p-4 shadow relative theme-container"
>
<form
method="POST"
action="/api/delete/income"
className="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;
if (
!confirm(
`Are you sure you want to delete "${
inc.source || "this income"
}"?`,
)
) {
e.preventDefault();
return false;
}
}}
>
<input type="hidden" name="income_id" value={inc.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/>
<button
type="submit"
aria-label="Delete income"
className="theme-icon-button button-reset hover:text-red-500 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="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">
{inc.source || ""}
</div>
<div class="text-sm theme-calendar-list-frequency">
Amount: ${inc.amount}
</div>
<div class="text-sm theme-calendar-list-frequency">
Frequency: {inc.frequency}
</div>
<div class="text-sm theme-calendar-list-frequency">
Received: {inc.received_date
? MakeNewDate(inc.received_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>
</>
);
}