integratedhub/islands/dialogs/AddIncomeDialog.tsx
2026-01-20 09:02:26 -06:00

230 lines
6.8 KiB
TypeScript

import { useEffect, useRef, useState } from "preact/hooks";
import {
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../../helpers.ts";
interface AddIncomeDialogProps {
onClose: () => void;
userId?: string;
}
type FieldErrorState = {
source: boolean;
amount: boolean;
};
export default function AddIncomeDialog(
{ onClose, userId }: AddIncomeDialogProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const formRef = useRef<HTMLFormElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrorState>({
source: false,
amount: false,
});
const today = new Date().toISOString().split("T")[0];
const addNewIncome = async () => {
if (!formRef.current) return;
// Collect form data
const formData = new FormData(formRef.current);
const source = formData.get("source")?.toString().trim() || "";
const amount = formData.get("amount")?.toString().trim() || "";
const frequency = formData.get("frequency")?.toString() || "Monthly";
const receivedDate = formData.get("received_date")?.toString() || today;
const sender = typeof location !== "undefined" ? location.pathname : "/";
const validation = {
source: !source,
amount: !amount,
};
setFieldErrors(validation);
if (validation.source || validation.amount) {
return;
}
setLoading(true);
localStorage.setItem("lock-route", "/calendar/every-income?modal=off");
// Build submission data
const submitData = new FormData();
submitData.set("sender", sender);
submitData.set("frequency", frequency);
submitData.set("received_date", receivedDate);
// Encrypt if password is available
const encryptionPassword = userId ? getEncryptionPassword(userId) : null;
if (encryptionPassword && (source || amount)) {
try {
const dataToEncrypt = {
source,
amount: parseFloat(amount) || 0,
};
const encryptedData = await encryptDataWithStoredPassword(
userId!,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
submitData.set("encrypted_data", encryptedData);
// Send empty plaintext when encrypted
submitData.set("source", "");
submitData.set("amount", "0");
} else {
throw new Error("Encryption returned empty result");
}
} catch (err) {
setLoading(false);
return;
}
} else {
submitData.set("source", source);
submitData.set("amount", amount);
submitData.set("encrypted_data", "");
}
// Submit via fetch - let redirects follow automatically
try {
const response = await fetch("/api/create/income", {
method: "POST",
body: submitData,
credentials: "same-origin",
});
// Fetch followed the redirect, now navigate to final URL or sender
if (response.ok) {
globalThis.location.href = "/calendar/every-income?modal=off";
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to create income");
}
} catch (err) {
setLoading(false);
}
};
const clearFieldError = (field: keyof FieldErrorState) => {
setFieldErrors((prev) => prev[field] ? { ...prev, [field]: false } : prev);
};
const getInputErrorClass = (hasError: boolean) =>
hasError ? "border-red-500 ring-1 ring-red-300" : "";
useEffect(() => {
firstInputRef.current?.focus();
}, []);
return (
<div
class="theme-dialog-overlay"
style={{ paddingTop: "calc(50vh - 400px)" }}
>
<div class="theme-dialog-container flex flex-col gap-2">
<form ref={formRef}>
<h2 class="theme-dialog-title">Add Income</h2>
<div class="flex flex-col gap-1 my-4">
<input
ref={firstInputRef}
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.source)
}`}
name="source"
placeholder="Source"
required
onInput={() => clearFieldError("source")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.source ? "visible" : "invisible"
}`}
aria-live="polite"
>
Source name is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.amount)
}`}
name="amount"
placeholder="Amount"
type="number"
step="0.01"
required
onInput={() => clearFieldError("amount")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.amount ? "visible" : "invisible"
}`}
aria-live="polite"
>
Amount is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<select
class="theme-dialog-select"
name="frequency"
defaultValue="Monthly"
>
<option value="Biweekly">Biweekly</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class="theme-dialog-input"
name="received_date"
type="date"
defaultValue={today}
required
/>
</div>
</form>
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
class={`theme-dialog-cancel-button w-40 ${
loading ? "opacity-70" : ""
}`}
onClick={() => {
localStorage.setItem(
"lock-route",
"/calendar/every-income?modal=off",
);
setLoading(true);
onClose();
}}
disabled={loading}
>
Cancel
</button>
<button
type="button"
class={`theme-dialog-submit-button w-40 ${
loading ? "opacity-70" : ""
}`}
disabled={loading}
onClick={addNewIncome}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</div>
</div>
);
}