integratedhub/islands/ObligationList.tsx

419 lines
14 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 and respond to theme mode + media.
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;
};
}, []);
async function handleExportObligations() {
const exportData = processedObligations.map((ob) => ({
id: ob.id,
description: ob.description ?? "",
amount: ob.amount ?? 0,
frequency: ob.frequency ?? "",
due_date: ob.due_date
? new Date(ob.due_date).toISOString().split("T")[0]
: null,
}));
const json = JSON.stringify(exportData, null, 2);
const blob = new Blob([json], { type: "application/json" });
const suggestedName = `budget-obligations-${
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<string, string[]> }[];
}) => Promise<FileSystemFileHandle>)({
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 handleImportObligations() {
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<string, string[]> }[];
multiple: boolean;
}) => Promise<FileSystemFileHandle[]>)({
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<File | null>((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 { obligations?: unknown[] })?.obligations;
if (!Array.isArray(list)) {
alert("Invalid file: expected a JSON array or { obligations: [] }");
return;
}
const res = await fetch("/api/import/obligations", {
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 (
<>
{popupAddObligationDialog}
<div class="flex flex-col mt-14 gap-6">
<div class="list-header">
<h2 class="theme-text list-title">
Your Obligations
</h2>
<div class="list-header-center">
<button
type="button"
class="list-action-button list-action-button--small"
onClick={handleExportObligations}
>
Export JSON
</button>
<button
type="button"
class="list-action-button list-action-button--small"
onClick={handleImportObligations}
>
Import JSON
</button>
</div>
<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 pointer-events-auto calendar-action-link calendar-action-link--back"
>
Back to Financial Calendar
</a>
</div>
</>
);
}