Y3:0
This commit is contained in:
parent
eadd939aa9
commit
3cc225deb0
|
|
@ -29,6 +29,15 @@ export default [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'react-hooks/static-components': 'off',
|
||||||
|
'react-hooks/refs': 'off',
|
||||||
|
'react-hooks/incompatible-library': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{structure.groups.map((group, groupIndex) => (
|
{structure.groups.map((group) => (
|
||||||
<div key={group.id}>
|
<div key={group.id}>
|
||||||
<Card className="gap-2 p-4">
|
<Card className="gap-2 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -145,8 +145,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{group.conditions.map(
|
{group.conditions.map((condition) => (
|
||||||
(condition, conditionIndex) => (
|
|
||||||
<div key={condition.id}>
|
<div key={condition.id}>
|
||||||
<ConditionRow
|
<ConditionRow
|
||||||
condition={condition}
|
condition={condition}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||||
import { decrypt, importKey } from '@/lib/crypto';
|
import { decrypt, importKey } from '@/lib/crypto';
|
||||||
import { getStoredKey } from '@/lib/key-storage';
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
type Length = number | { min: number; max: number } | null;
|
type Length = number | { min: number; max: number } | null;
|
||||||
|
|
||||||
|
|
@ -48,6 +48,14 @@ function generateMaskedText(targetLength: number): string {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialDisplayState(isKeySet: boolean): DisplayState {
|
||||||
|
if (!isKeySet) {
|
||||||
|
return 'encrypted';
|
||||||
|
}
|
||||||
|
const keyString = getStoredKey();
|
||||||
|
return keyString ? 'loading' : 'encrypted';
|
||||||
|
}
|
||||||
|
|
||||||
export function EncryptedText(props: EncryptedTextProps) {
|
export function EncryptedText(props: EncryptedTextProps) {
|
||||||
const { encryptedText, iv, className = '', length = null } = props;
|
const { encryptedText, iv, className = '', length = null } = props;
|
||||||
const { isKeySet } = useEncryptionKey();
|
const { isKeySet } = useEncryptionKey();
|
||||||
|
|
@ -57,49 +65,42 @@ export function EncryptedText(props: EncryptedTextProps) {
|
||||||
);
|
);
|
||||||
const maskedValue = useMemo(
|
const maskedValue = useMemo(
|
||||||
() => generateMaskedText(targetLength),
|
() => generateMaskedText(targetLength),
|
||||||
[targetLength, encryptedText, iv],
|
[targetLength],
|
||||||
);
|
);
|
||||||
const [cachedDecryption, setCachedDecryption] = useState<{
|
const [cachedDecryption, setCachedDecryption] = useState<{
|
||||||
encryptedText: string;
|
encryptedText: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
value: string;
|
value: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [displayState, setDisplayState] = useState<DisplayState>(() => {
|
const [displayState, setDisplayState] = useState<DisplayState>(() => getInitialDisplayState(isKeySet));
|
||||||
if (!isKeySet) {
|
const prevIsKeySetRef = useRef(isKeySet);
|
||||||
return 'encrypted';
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyString = getStoredKey();
|
|
||||||
return keyString ? 'loading' : 'encrypted';
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isKeySet) {
|
const wasKeySet = prevIsKeySetRef.current;
|
||||||
setCachedDecryption(null);
|
prevIsKeySetRef.current = isKeySet;
|
||||||
setDisplayState('encrypted');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (!isKeySet) {
|
||||||
cachedDecryption &&
|
if (wasKeySet) {
|
||||||
cachedDecryption.encryptedText === encryptedText &&
|
setCachedDecryption(null);
|
||||||
cachedDecryption.iv === iv
|
setDisplayState('encrypted');
|
||||||
) {
|
}
|
||||||
setDisplayState((current) =>
|
|
||||||
current === 'decrypted' ? current : 'decrypted',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyString = getStoredKey();
|
const keyString = getStoredKey();
|
||||||
if (!keyString) {
|
if (!keyString) {
|
||||||
setCachedDecryption(null);
|
if (wasKeySet !== isKeySet) {
|
||||||
setDisplayState('encrypted');
|
setCachedDecryption(null);
|
||||||
|
setDisplayState('encrypted');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!wasKeySet && isKeySet) {
|
||||||
|
setDisplayState('loading');
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setDisplayState('loading');
|
|
||||||
|
|
||||||
importKey(keyString)
|
importKey(keyString)
|
||||||
.then((key) => decrypt(encryptedText, key, iv))
|
.then((key) => decrypt(encryptedText, key, iv))
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,22 @@ import {
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||||
import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react';
|
import { Check, ChevronsUpDown, HelpCircle, type LucideIcon } from 'lucide-react';
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
function resolveIconComponent(iconName?: string): Icons.LucideIcon {
|
const iconCache = new Map<string, LucideIcon>();
|
||||||
const icon = Icons[iconName as keyof typeof Icons];
|
|
||||||
return icon as Icons.LucideIcon;
|
function getIconComponent(iconName?: string): LucideIcon | null {
|
||||||
|
if (!iconName) return null;
|
||||||
|
if (iconCache.has(iconName)) {
|
||||||
|
return iconCache.get(iconName)!;
|
||||||
|
}
|
||||||
|
const icon = Icons[iconName as keyof typeof Icons] as LucideIcon | undefined;
|
||||||
|
if (icon) {
|
||||||
|
iconCache.set(iconName, icon);
|
||||||
|
}
|
||||||
|
return icon ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryComboboxProps {
|
interface CategoryComboboxProps {
|
||||||
|
|
@ -144,9 +153,9 @@ export function CategoryCombobox({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryIcon({ category }: { category: Category }) {
|
const CategoryIcon = memo(function CategoryIcon({ category }: { category: Category }) {
|
||||||
const colorClasses = getCategoryColorClasses(category.color);
|
const colorClasses = getCategoryColorClasses(category.color);
|
||||||
const IconComponent = resolveIconComponent(category.icon);
|
const iconName = category.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -155,8 +164,14 @@ function CategoryIcon({ category }: { category: Category }) {
|
||||||
colorClasses.bg,
|
colorClasses.bg,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconComponent className={cn('h-3 w-3', colorClasses.text)} />
|
<DynamicIcon name={iconName} className={cn('h-3 w-3', colorClasses.text)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
const DynamicIcon = memo(function DynamicIcon({ name, className }: { name?: string; className?: string }) {
|
||||||
|
const Icon = getIconComponent(name);
|
||||||
|
if (!Icon) return null;
|
||||||
|
return <Icon className={className} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from '@/components/ui/drawer';
|
} from '@/components/ui/drawer';
|
||||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||||
import { decrypt, importKey } from '@/lib/crypto';
|
import { importKey } from '@/lib/crypto';
|
||||||
import {
|
import {
|
||||||
autoDetectColumns,
|
autoDetectColumns,
|
||||||
convertRowsToTransactions,
|
convertRowsToTransactions,
|
||||||
|
|
@ -18,10 +18,7 @@ import {
|
||||||
saveImportConfig,
|
saveImportConfig,
|
||||||
} from '@/lib/import-config-storage';
|
} from '@/lib/import-config-storage';
|
||||||
import { getStoredKey } from '@/lib/key-storage';
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
import {
|
import { evaluateRulesForNewTransaction } from '@/lib/rule-engine';
|
||||||
evaluateRules,
|
|
||||||
evaluateRulesForNewTransaction,
|
|
||||||
} from '@/lib/rule-engine';
|
|
||||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||||
import { accountSyncService } from '@/services/account-sync';
|
import { accountSyncService } from '@/services/account-sync';
|
||||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||||
|
|
@ -34,7 +31,7 @@ import {
|
||||||
type ImportState,
|
type ImportState,
|
||||||
} from '@/types/import';
|
} from '@/types/import';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Check, Loader2 } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ImportStepAccount } from './import-step-account';
|
import { ImportStepAccount } from './import-step-account';
|
||||||
|
|
@ -320,7 +317,7 @@ export function ImportTransactionsDrawer({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdTransactions: any[] = [];
|
const createdTransactions: unknown[] = [];
|
||||||
const errors: ImportError[] = [];
|
const errors: ImportError[] = [];
|
||||||
const keyString = getStoredKey();
|
const keyString = getStoredKey();
|
||||||
const key = keyString ? await importKey(keyString) : null;
|
const key = keyString ? await importKey(keyString) : null;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
interface DataTablePaginationProps {
|
interface DataTablePaginationProps {
|
||||||
rowCountLabel?: string;
|
rowCountLabel?: string;
|
||||||
displayedCount?: number;
|
displayedCount?: number;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
Row,
|
||||||
Table as TableType,
|
Table as TableType,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer, VirtualItem, Virtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
|
@ -19,7 +20,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
table: TableType<TData>;
|
table: TableType<TData>;
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
renderRow?: (row: any, virtualRow: any, rowVirtualizer: any) => React.ReactNode;
|
renderRow?: (row: Row<TData>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function useAppearance() {
|
||||||
'appearance',
|
'appearance',
|
||||||
) as Appearance | null;
|
) as Appearance | null;
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
updateAppearance(savedAppearance || 'system');
|
updateAppearance(savedAppearance || 'system');
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export interface PendingChange {
|
||||||
id?: number;
|
id?: number;
|
||||||
store: string;
|
store: string;
|
||||||
operation: 'create' | 'update' | 'delete';
|
operation: 'create' | 'update' | 'delete';
|
||||||
data: any;
|
data: Record<string, unknown>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||||
is_not_empty: 'is not empty',
|
is_not_empty: 'is not empty',
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildConditionJsonLogic(condition: Condition): Record<string, any> {
|
type JsonLogicRule = Record<string, unknown>;
|
||||||
|
|
||||||
|
function buildConditionJsonLogic(condition: Condition): JsonLogicRule {
|
||||||
const { field, operator, value } = condition;
|
const { field, operator, value } = condition;
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
|
|
@ -100,7 +102,7 @@ function buildConditionJsonLogic(condition: Condition): Record<string, any> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGroupJsonLogic(group: ConditionGroup): Record<string, any> {
|
function buildGroupJsonLogic(group: ConditionGroup): JsonLogicRule {
|
||||||
if (group.conditions.length === 0) {
|
if (group.conditions.length === 0) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +115,7 @@ function buildGroupJsonLogic(group: ConditionGroup): Record<string, any> {
|
||||||
return { [group.operator]: conditions };
|
return { [group.operator]: conditions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildJsonLogic(structure: RuleStructure): Record<string, any> {
|
export function buildJsonLogic(structure: RuleStructure): JsonLogicRule {
|
||||||
const validGroups = structure.groups.filter(
|
const validGroups = structure.groups.filter(
|
||||||
(group) => group.conditions.length > 0,
|
(group) => group.conditions.length > 0,
|
||||||
);
|
);
|
||||||
|
|
@ -131,7 +133,7 @@ export function buildJsonLogic(structure: RuleStructure): Record<string, any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConditionFromJsonLogic(
|
function parseConditionFromJsonLogic(
|
||||||
jsonLogic: Record<string, any>,
|
jsonLogic: JsonLogicRule,
|
||||||
): Condition | null {
|
): Condition | null {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
|
@ -211,7 +213,7 @@ function parseConditionFromJsonLogic(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseJsonLogic(jsonLogic: Record<string, any>): RuleStructure {
|
export function parseJsonLogic(jsonLogic: JsonLogicRule): RuleStructure {
|
||||||
const defaultStructure: RuleStructure = {
|
const defaultStructure: RuleStructure = {
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ export interface IndexedDBRecord {
|
||||||
user_id?: UUID | null;
|
user_id?: UUID | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
[key: string]: any;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncOptions {
|
export interface SyncOptions {
|
||||||
storeName: StoreName;
|
storeName: StoreName;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
transformFromServer?: (data: any) => any;
|
transformFromServer?: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||||
transformToServer?: (data: any) => any;
|
transformToServer?: (data: Record<string, unknown>) => Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
|
|
@ -99,7 +99,7 @@ export class SyncManager {
|
||||||
private async syncFromServer(result: SyncResult): Promise<void> {
|
private async syncFromServer(result: SyncResult): Promise<void> {
|
||||||
const lastSync = await this.getLastSyncTime();
|
const lastSync = await this.getLastSyncTime();
|
||||||
|
|
||||||
const params: any = {};
|
const params: Record<string, string> = {};
|
||||||
if (lastSync) {
|
if (lastSync) {
|
||||||
params.since = lastSync;
|
params.since = lastSync;
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ export class SyncManager {
|
||||||
|
|
||||||
const table = db[this.options.storeName];
|
const table = db[this.options.storeName];
|
||||||
const localRecords = await table.toArray();
|
const localRecords = await table.toArray();
|
||||||
const localMap = new Map(localRecords.map((r: any) => [r.id, r]));
|
const localMap = new Map(localRecords.map((r) => [(r as IndexedDBRecord).id, r as IndexedDBRecord]));
|
||||||
|
|
||||||
for (const serverRecord of serverData) {
|
for (const serverRecord of serverData) {
|
||||||
const transformed = this.options.transformFromServer
|
const transformed = this.options.transformFromServer
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -27,6 +27,21 @@ import {
|
||||||
import { storeKey } from '@/lib/key-storage';
|
import { storeKey } from '@/lib/key-storage';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
|
||||||
|
function isCryptoAvailable(): boolean {
|
||||||
|
if (typeof window === 'undefined') return true;
|
||||||
|
return !!(window.crypto && window.crypto.subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialErrors(): { password?: string; confirmPassword?: string; general?: string } {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
if (!window.crypto || !window.crypto.subtle) {
|
||||||
|
return {
|
||||||
|
general: 'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
export default function SetupEncryption() {
|
export default function SetupEncryption() {
|
||||||
const { refreshKeyState } = useEncryptionKey();
|
const { refreshKeyState } = useEncryptionKey();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -35,22 +50,12 @@ export default function SetupEncryption() {
|
||||||
'session' | 'persistent'
|
'session' | 'persistent'
|
||||||
>('session');
|
>('session');
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [cryptoAvailable, setCryptoAvailable] = useState(true);
|
const [cryptoAvailable] = useState(isCryptoAvailable);
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
password?: string;
|
password?: string;
|
||||||
confirmPassword?: string;
|
confirmPassword?: string;
|
||||||
general?: string;
|
general?: string;
|
||||||
}>({});
|
}>(getInitialErrors);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!window.crypto || !window.crypto.subtle) {
|
|
||||||
setCryptoAvailable(false);
|
|
||||||
setErrors({
|
|
||||||
general:
|
|
||||||
'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
|
Cell,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
|
@ -7,6 +8,7 @@ import {
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
Row,
|
||||||
SortingState,
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
VisibilityState,
|
VisibilityState,
|
||||||
|
|
@ -109,7 +111,7 @@ function AccountActions({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) {
|
function AccountRow({ row, onSuccess }: { row: Row<Account>; onSuccess?: () => void }) {
|
||||||
const account = row.original;
|
const account = row.original;
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
@ -127,7 +129,7 @@ function AccountRow({ row, onSuccess }: { row: any; onSuccess?: () => void }) {
|
||||||
>
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell: any) => (
|
.map((cell: Cell<Account, unknown>) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
|
Cell,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
Row,
|
||||||
SortingState,
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
VisibilityState,
|
VisibilityState,
|
||||||
|
|
@ -101,7 +103,7 @@ function AutomationRuleActions({ rule }: { rule: AutomationRule }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutomationRuleRow({ row }: { row: any }) {
|
function AutomationRuleRow({ row }: { row: Row<AutomationRule> }) {
|
||||||
const rule = row.original;
|
const rule = row.original;
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
@ -119,7 +121,7 @@ function AutomationRuleRow({ row }: { row: any }) {
|
||||||
>
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell: any) => (
|
.map((cell: Cell<AutomationRule, unknown>) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
|
Cell,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
Row,
|
||||||
SortingState,
|
SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
VisibilityState,
|
VisibilityState,
|
||||||
|
|
@ -101,7 +103,7 @@ function CategoryActions({ category }: { category: Category }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryRow({ row }: { row: any }) {
|
function CategoryRow({ row }: { row: Row<Category> }) {
|
||||||
const category = row.original;
|
const category = row.original;
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
@ -119,7 +121,7 @@ function CategoryRow({ row }: { row: any }) {
|
||||||
>
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map((cell: any) => (
|
.map((cell: Cell<Category, unknown>) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import {
|
import {
|
||||||
|
Cell,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
|
Row,
|
||||||
SortingState,
|
SortingState,
|
||||||
VisibilityState,
|
VisibilityState,
|
||||||
flexRender,
|
flexRender,
|
||||||
|
|
@ -9,6 +11,7 @@ import {
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
|
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
|
||||||
import { isWithinInterval, parseISO } from 'date-fns';
|
import { isWithinInterval, parseISO } from 'date-fns';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
@ -77,6 +80,64 @@ interface Props {
|
||||||
|
|
||||||
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
|
||||||
|
|
||||||
|
interface TransactionRowProps {
|
||||||
|
row: Row<DecryptedTransaction>;
|
||||||
|
virtualRow: VirtualItem;
|
||||||
|
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
|
||||||
|
onEdit: (transaction: DecryptedTransaction) => void;
|
||||||
|
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
|
||||||
|
onDelete: (transaction: DecryptedTransaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionRowComponent({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
rowVirtualizer,
|
||||||
|
onEdit,
|
||||||
|
onReEvaluateRules,
|
||||||
|
onDelete,
|
||||||
|
}: TransactionRowProps) {
|
||||||
|
const transaction = row.original;
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
{ }
|
||||||
|
<TableRow
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-state={(row.getIsSelected() || contextMenuOpen) && 'selected'}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell: Cell<DecryptedTransaction, unknown>) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuLabel>Actions</ContextMenuLabel>
|
||||||
|
<ContextMenuItem onClick={() => onEdit(transaction)}>
|
||||||
|
Edit
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => onReEvaluateRules(transaction)}>
|
||||||
|
Re-evaluate rules
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => onDelete(transaction)}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getInitialColumnVisibility(): VisibilityState {
|
function getInitialColumnVisibility(): VisibilityState {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY);
|
||||||
|
|
@ -950,66 +1011,21 @@ export default function Transactions({ categories, accounts, banks }: Props) {
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransactionRow = useCallback(
|
|
||||||
({ row, virtualRow, rowVirtualizer }: { row: any; virtualRow: any; rowVirtualizer: any }) => {
|
|
||||||
const transaction = row.original;
|
|
||||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu key={row.id} onOpenChange={setContextMenuOpen}>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<TableRow
|
|
||||||
ref={rowVirtualizer.measureElement}
|
|
||||||
data-state={(row.getIsSelected() || contextMenuOpen) && 'selected'}
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell: any) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuLabel>Actions</ContextMenuLabel>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => setEditTransaction(transaction)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => handleReEvaluateRules(transaction)}
|
|
||||||
>
|
|
||||||
Re-evaluate rules
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => setDeleteTransaction(transaction)}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[handleReEvaluateRules],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTransactionRow = useCallback(
|
const renderTransactionRow = useCallback(
|
||||||
(row: any, virtualRow: any, rowVirtualizer: any) => {
|
(row: Row<DecryptedTransaction>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => {
|
||||||
return (
|
return (
|
||||||
<TransactionRow
|
<TransactionRowComponent
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
virtualRow={virtualRow}
|
virtualRow={virtualRow}
|
||||||
rowVirtualizer={rowVirtualizer}
|
rowVirtualizer={rowVirtualizer}
|
||||||
|
onEdit={setEditTransaction}
|
||||||
|
onReEvaluateRules={handleReEvaluateRules}
|
||||||
|
onDelete={setDeleteTransaction}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[TransactionRow],
|
[handleReEvaluateRules],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import {
|
import {
|
||||||
LockIcon,
|
LockIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class AccountSyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: Omit<Account, 'id'>): Promise<Account> {
|
async create(data: Omit<Account, 'id'>): Promise<Account> {
|
||||||
return await this.syncManager.createLocal<Account>(data as any);
|
return await this.syncManager.createLocal<Account>(data as Omit<Account, 'id' | 'created_at' | 'updated_at'>);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: UUID, data: Partial<Account>): Promise<void> {
|
async update(id: UUID, data: Partial<Account>): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class CategorySyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: Omit<Category, 'id'>): Promise<Category> {
|
async create(data: Omit<Category, 'id'>): Promise<Category> {
|
||||||
return await this.syncManager.createLocal<Category>(data as any);
|
return await this.syncManager.createLocal<Category>(data as Omit<Category, 'id' | 'created_at' | 'updated_at'>);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: UUID, data: Partial<Category>): Promise<void> {
|
async update(id: UUID, data: Partial<Category>): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface AutomationRule {
|
||||||
user_id: UUID;
|
user_id: UUID;
|
||||||
title: string;
|
title: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
rules_json: Record<string, any>;
|
rules_json: Record<string, unknown>;
|
||||||
action_category_id: UUID | null;
|
action_category_id: UUID | null;
|
||||||
action_note: string | null;
|
action_note: string | null;
|
||||||
action_note_iv: string | null;
|
action_note_iv: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue