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 and respond to theme mode + media. const [_popAddIncome, popupAddIncomeDialog] = PopAddIncomeDialog({ userId, defaultOpen: openModal, }); const [processedIncomes, setProcessedIncomes] = useState(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(); }, []); async function handleExportIncomes() { const exportData = processedIncomes.map((inc) => ({ id: inc.id, source: inc.source ?? "", amount: inc.amount ?? 0, frequency: inc.frequency ?? "", received_date: inc.received_date ? new Date(inc.received_date).toISOString().split("T")[0] : null, })); const json = JSON.stringify(exportData, null, 2); const blob = new Blob([json], { type: "application/json" }); const suggestedName = `incomes-${ new Date().toISOString().split("T")[0] }.json`; const showSave = (globalThis as { showSaveFilePicker?: unknown }) .showSaveFilePicker; if (typeof showSave === "function") { try { const handle = await (showSave as (opts: { suggestedName: string; types: { description: string; accept: Record }[]; }) => Promise)({ suggestedName, types: [{ description: "JSON file", accept: { "application/json": [".json"] }, }], }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Export failed:", err); fallbackDownload(blob, suggestedName); } } } else { fallbackDownload(blob, suggestedName); } } function fallbackDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function handleImportIncomes() { let file: File; const showOpen = (globalThis as { showOpenFilePicker?: unknown }) .showOpenFilePicker; if (typeof showOpen === "function") { try { const [handle] = await (showOpen as (opts: { types: { description: string; accept: Record }[]; multiple: boolean; }) => Promise)({ types: [{ description: "JSON file", accept: { "application/json": [".json"] }, }], multiple: false, }); file = await handle.getFile(); } catch (err) { if ((err as Error).name !== "AbortError") { console.error("Import file picker failed:", err); } return; } } else { const chosen = await new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json,.json"; input.onchange = () => resolve(input.files?.[0] ?? null); input.click(); }); if (!chosen) return; file = chosen; } try { const text = await file.text(); const data = JSON.parse(text) as unknown; const list = Array.isArray(data) ? data : (data as { incomes?: unknown[] })?.incomes; if (!Array.isArray(list)) { alert("Invalid file: expected a JSON array or { incomes: [] }"); return; } const res = await fetch("/api/import/incomes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(list), }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err?.error ?? "Import failed"); return; } const result = await res.json() as { added: number; skipped: number }; if (result.added > 0 || result.skipped > 0) { globalThis.location.reload(); } } catch (e) { console.error("Import failed:", e); alert("Invalid JSON or import failed."); } } return ( <> {popupAddIncomeDialog}

Your Incomes

    {processedIncomes.map((inc) => (
  • { // 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; } }} >
    {inc.source || ""}
    Amount: ${inc.amount}
    Frequency: {inc.frequency}
    Received: {inc.received_date ? MakeNewDate(inc.received_date).toISOString().split( "T", )[0] : ""}
  • ))}
); }