230 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|