Add support for filtering dates
This commit is contained in:
7
web/package-lock.json
generated
7
web/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bulma": "^1.0.4",
|
"bulma": "^1.0.4",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -3695,6 +3696,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||||
|
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bulma": "^1.0.4",
|
"bulma": "^1.0.4",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ buildNpmPackage {
|
|||||||
pname = "actual-importer-frontend";
|
pname = "actual-importer-frontend";
|
||||||
version = "0.0.1";
|
version = "0.0.1";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
npmDepsHash = "sha256-oNs/nnATxuR8mrIRznzIBk5/gWIR+Ie/i8PD2HhYYFA=";
|
npmDepsHash = "sha256-/G8OZBAkCuc0LTmpB1v8HTgaWdYd9AJfZTC0/eUQIx0=";
|
||||||
}
|
}
|
||||||
|
|||||||
121
web/src/pages/PrepareTransactionsPage/FilterPanel.tsx
Normal file
121
web/src/pages/PrepareTransactionsPage/FilterPanel.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Dayjs } from "dayjs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export type FilterData = {
|
||||||
|
from?: Dayjs;
|
||||||
|
to?: Dayjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterPanelProps = {
|
||||||
|
value?: FilterData;
|
||||||
|
setValue: (value: FilterData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const str2dayjs = (text?: string) => text ? dayjs(text) : undefined;
|
||||||
|
const dayjs2str = (date?: Dayjs) => !date ? "" : date.format("YYYY-MM-DD");
|
||||||
|
|
||||||
|
export function FilterPanel({ value, setValue }: FilterPanelProps) {
|
||||||
|
const handleChange = useCallback(<K extends keyof FilterData>(k: K, v: FilterData[K]) => {
|
||||||
|
setValue({ ...value, [k]: v });
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleQuickDate = useCallback((key: 'from'|'to', days: number) => {
|
||||||
|
const date = dayjs().subtract(days, 'days');
|
||||||
|
|
||||||
|
setValue({
|
||||||
|
...value,
|
||||||
|
[key]: date
|
||||||
|
});
|
||||||
|
}, [value, setValue]);
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label">Date range</label>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<div className="control is-expanded">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={dayjs2str(value?.from)}
|
||||||
|
onChange={e => handleChange('from', str2dayjs(e.target.value))}
|
||||||
|
/>
|
||||||
|
<p className="help"><strong>From</strong> (transactions not earlier than...)</p>
|
||||||
|
<div className="field is-grouped">
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 0)}>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 1)}>
|
||||||
|
Yesterday
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 2)}>
|
||||||
|
-2d
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 3)}>
|
||||||
|
-3d
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<div className="control is-expanded">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
value={dayjs2str(value?.to)}
|
||||||
|
onChange={e => handleChange('to', str2dayjs(e.target.value))}
|
||||||
|
/>
|
||||||
|
<p className="help"><strong>To</strong> (transactions not later than...)</p>
|
||||||
|
<div className="field is-grouped">
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 0)}>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 1)}>
|
||||||
|
Yesterday
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 2)}>
|
||||||
|
-2d
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 3)}>
|
||||||
|
-3d
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field is-horizontal">
|
||||||
|
<div className="field-label is-normal">
|
||||||
|
<label className="label"/>
|
||||||
|
</div>
|
||||||
|
<div className="field-body">
|
||||||
|
<div className="field">
|
||||||
|
<div className="control">
|
||||||
|
<button className="button is-danger is-outlined" onClick={() => setValue({})}>
|
||||||
|
Reset filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useStore } from "../../store/AppStore";
|
import { useStore } from "../../store/AppStore";
|
||||||
import TransactionsTable from "./TransactionsTable";
|
import TransactionsTable from "./TransactionsTable";
|
||||||
import { SkippedLines } from "./SkippedLines";
|
import { SkippedLines } from "./SkippedLines";
|
||||||
@@ -6,19 +6,48 @@ import ImportBar from "./ImportBar";
|
|||||||
import type { ImportOptions } from "../../types/api";
|
import type { ImportOptions } from "../../types/api";
|
||||||
import { submitTransactions } from "../../services/api.service";
|
import { submitTransactions } from "../../services/api.service";
|
||||||
import { useLoader } from "../../hooks/useLoader";
|
import { useLoader } from "../../hooks/useLoader";
|
||||||
|
import { FilterPanel, type FilterData } from "./FilterPanel";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
|
||||||
export default function PrepareTransactionsPage() {
|
export default function PrepareTransactionsPage() {
|
||||||
const { state, dispatch } = useStore();
|
const { state, dispatch } = useStore();
|
||||||
|
|
||||||
const desiredTransactions = useMemo(() => state.transactions.filter((_, i) => !state.filteredOut.includes(i)), [state, state.filteredOut]);
|
const [userFilter, setUserFilter] = useState<FilterData>({});
|
||||||
|
const [deselected, setDeselected] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const select = useCallback((index: number) => setDeselected(deselected.filter(x => x !== index)), [deselected, setDeselected]);
|
||||||
|
const deselect = useCallback((index: number) => setDeselected([...deselected, index]), [deselected, setDeselected]);
|
||||||
|
|
||||||
|
const filteredTransactions = useMemo(() => state.transactions
|
||||||
|
.filter(t => {
|
||||||
|
if (!userFilter.from) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = dayjs(t.date);
|
||||||
|
|
||||||
|
return date.isAfter(userFilter.from, 'day') || date.isSame(userFilter.from, 'day');
|
||||||
|
})
|
||||||
|
.filter(t => {
|
||||||
|
if (!userFilter.to) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = dayjs(t.date);
|
||||||
|
|
||||||
|
return date.isBefore(userFilter.to, 'day') || date.isSame(userFilter.to, 'day');
|
||||||
|
})
|
||||||
|
, [state.transactions, userFilter]);
|
||||||
|
|
||||||
|
const selectedTransactions = useMemo(() => filteredTransactions.filter((_, i) => !deselected.includes(i)), [filteredTransactions, deselected]);
|
||||||
|
|
||||||
const { fn: handleSubmit, loading } = useLoader(async (opts: ImportOptions) => {
|
const { fn: handleSubmit, loading } = useLoader(async (opts: ImportOptions) => {
|
||||||
if(!state.server) {
|
if(!state.server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await submitTransactions(desiredTransactions, state.server, opts);
|
const response = await submitTransactions(selectedTransactions, state.server, opts);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SET_RESULT',
|
type: 'SET_RESULT',
|
||||||
@@ -31,17 +60,9 @@ export default function PrepareTransactionsPage() {
|
|||||||
offset: 1
|
offset: 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [desiredTransactions, state]);
|
}, [selectedTransactions, state]);
|
||||||
|
|
||||||
const filterOut = useCallback((index: number) => dispatch({
|
useEffect(() => setDeselected([]), [filteredTransactions]);
|
||||||
type: 'FILTER_OUT',
|
|
||||||
payload: { index }
|
|
||||||
}), [dispatch]);
|
|
||||||
|
|
||||||
const removeFilter = useCallback((index: number) => dispatch({
|
|
||||||
type: 'REMOVE_FILTER',
|
|
||||||
payload: { index }
|
|
||||||
}), [dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="columns mt-6">
|
<div className="columns mt-6">
|
||||||
@@ -50,12 +71,17 @@ export default function PrepareTransactionsPage() {
|
|||||||
<h2 className="title is-4 has-text-centered">Prepare Transactions</h2>
|
<h2 className="title is-4 has-text-centered">Prepare Transactions</h2>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<h3 className="title is-5">Considered transactions ({state.transactions.length-state.filteredOut.length}/{state.transactions.length})</h3>
|
<h3 className="title is-5">Filters</h3>
|
||||||
|
<FilterPanel value={userFilter} setValue={setUserFilter} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content">
|
||||||
|
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
|
||||||
<TransactionsTable
|
<TransactionsTable
|
||||||
transactions={state.transactions}
|
transactions={filteredTransactions}
|
||||||
filteredOut={state.filteredOut}
|
deselected={deselected}
|
||||||
filterOut={filterOut}
|
deselect={deselect}
|
||||||
removeFilter={removeFilter} />
|
select={select} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
|
|||||||
@@ -4,31 +4,31 @@ import styles from "./TransactionTable.module.css";
|
|||||||
|
|
||||||
export type TransactionsTableProps = {
|
export type TransactionsTableProps = {
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
filteredOut: number[];
|
deselected: number[];
|
||||||
filterOut: (index: number) => void;
|
deselect: (index: number) => void;
|
||||||
removeFilter: (index: number) => void;
|
select: (index: number) => void;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransactionsTable({ transactions, filteredOut, filterOut, removeFilter, readonly }: TransactionsTableProps) {
|
export default function TransactionsTable({ transactions, deselected, deselect, select, readonly }: TransactionsTableProps) {
|
||||||
|
|
||||||
const changeSelection = useCallback((index: number) => {
|
const changeSelection = useCallback((index: number) => {
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(filteredOut.includes(index)) {
|
if(deselected.includes(index)) {
|
||||||
removeFilter(index);
|
select(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
else {
|
else {
|
||||||
filterOut(index);
|
deselect(index);
|
||||||
}
|
}
|
||||||
}, [filteredOut, filterOut, removeFilter]);
|
}, [deselected, deselect, select]);
|
||||||
|
|
||||||
|
|
||||||
const renderRow = useCallback((transaction: Transaction, index: number) => (
|
const renderRow = useCallback((transaction: Transaction, index: number) => (
|
||||||
<tr key={index} onClick={() => changeSelection(index)} className={`${!readonly && filteredOut.includes(index) ? styles.disabled : ""}`}>
|
<tr key={index} onClick={() => changeSelection(index)} className={`${!readonly && deselected.includes(index) ? styles.disabled : ""}`}>
|
||||||
<td>{transaction.kind}</td>
|
<td>{transaction.kind}</td>
|
||||||
<td>{transaction.date}</td>
|
<td>{transaction.date}</td>
|
||||||
<td>{transaction.from}</td>
|
<td>{transaction.from}</td>
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ export function reducer(state: State, action: Action): State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'FILTER_OUT':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
filteredOut: [...state.filteredOut, action.payload.index]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'REMOVE_FILTER':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
filteredOut: state.filteredOut.filter(x => x !== action.payload.index)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'SET_RESULT':
|
case 'SET_RESULT':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -9,6 +9,4 @@ export const initialState: State = {
|
|||||||
wizard: {
|
wizard: {
|
||||||
step: 0
|
step: 0
|
||||||
},
|
},
|
||||||
|
|
||||||
filteredOut: []
|
|
||||||
};
|
};
|
||||||
@@ -14,8 +14,6 @@ export type State = {
|
|||||||
step: number;
|
step: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
filteredOut: number[];
|
|
||||||
|
|
||||||
result?: {
|
result?: {
|
||||||
errors?: {
|
errors?: {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -57,18 +55,6 @@ export type Action =
|
|||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'FILTER_OUT',
|
|
||||||
payload: {
|
|
||||||
index: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'REMOVE_FILTER',
|
|
||||||
payload: {
|
|
||||||
index: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: 'SET_RESULT',
|
type: 'SET_RESULT',
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
Reference in New Issue
Block a user