Merge pull request #195 from goteo/feat/charges-search-filters-tags

[feat] Charges Search filters tags + restructure Contributions.svelte (other sub task has been discovered)
This commit is contained in:
2026-01-12 12:08:55 +01:00
committed by GitHub
13 changed files with 239 additions and 79 deletions
+25 -12
View File
@@ -1,7 +1,12 @@
<script>
<script lang="ts">
import Slider from "./Slider.svelte";
import Filters from "./Filters.svelte";
import Table from "./Table.svelte";
import Categories from "./Categories.svelte";
import FiltersTags from "./FiltersTags.svelte";
import { t } from "../../i18n/store";
import ExportCsv from "./ExportCsv.svelte";
import type { ApiGatewayChargesGetCollectionData } from "../../openapi/client";
/* TODO: Add data to slider and set locales */
const slides = [
@@ -12,20 +17,28 @@
{ title: "Slide 5", amount: "250,99€" },
];
let filters = $state({
paymentMethod: "",
chargeStatus: "",
rangeAmount: "",
from: undefined,
to: undefined,
target: undefined,
});
let filters: ApiGatewayChargesGetCollectionData["query"] = $state({});
function handleApplyFilters(newFilters) {
function handleApplyFilters(newFilters: ApiGatewayChargesGetCollectionData["query"]) {
filters = { ...filters, ...newFilters };
}
</script>
<Filters onApplyFilters={handleApplyFilters} currentTarget={filters.target} />
<Slider {slides} />
<div>
<Filters {filters} onApplyFilters={handleApplyFilters} currentTarget={filters?.target} />
<div>
<div>
<FiltersTags
onCloseFilter={handleApplyFilters}
title={$t("admin.charges.lastContributions")}
{filters}
/>
<!-- TODO: Move "Export .csv" button to this component (from Filters.svelte) -->
<ExportCsv />
</div>
<!-- TODO: Implement Categories component -->
<Categories />
<Slider {slides} />
</div>
</div>
<Table {filters} />
+30 -18
View File
@@ -3,11 +3,15 @@
import ActiveFilterIcon from "../../svgs/ActiveFilterIcon.svelte";
import Search from "./Search.svelte";
import { apiGatewaysGetCollection } from "../../openapi/client/index";
import { apiGatewayChargesGetCollection } from "../../openapi/client/index";
import {
apiGatewayChargesGetCollection,
type ApiGatewayChargesGetCollectionData,
} from "../../openapi/client/index";
import { t } from "../../i18n/store";
import { onMount } from "svelte";
let { onApplyFilters, currentTarget } = $props<{
let { filters, onApplyFilters, currentTarget } = $props<{
filters: ApiGatewayChargesGetCollectionData["query"];
onApplyFilters: (filters: any) => void;
currentTarget?: string;
}>();
@@ -54,15 +58,17 @@
alert($t("contributions.filters.dateRange.errors.invalidRange"));
return;
}
onApplyFilters({
paymentMethod: selectedPaymentMethod,
chargeStatus: selectedChargeStatus,
rangeAmount: selectedRangeAmount,
from: dateFrom
? new Date(new Date(dateFrom).getTime() - 24 * 60 * 60 * 1000).toISOString()
...filters,
"checkout.gateway": selectedPaymentMethod || undefined,
status: selectedChargeStatus || undefined,
"money.amount[gte]": selectedRangeAmount || undefined,
"dateCreated[after]": dateFrom
? new Date(new Date(dateFrom).getTime()).toISOString()
: undefined,
to: dateTo
? new Date(new Date(dateTo).getTime() + 24 * 60 * 60 * 1000).toISOString()
"dateCreated[before]": dateTo
? new Date(new Date(dateTo).getTime()).toISOString()
: undefined,
});
}
@@ -94,17 +100,13 @@
}
if (dateFrom) {
const fromDate = new Date(
new Date(dateFrom).getTime() - 24 * 60 * 60 * 1000,
).toISOString();
const fromDate = new Date(new Date(dateFrom).getTime()).toISOString();
queryParams["dateCreated[strictly_after]"] = fromDate;
filenameParams.from = dateFrom;
}
if (dateTo) {
const toDate = new Date(
new Date(dateTo).getTime() + 24 * 60 * 60 * 1000,
).toISOString();
const toDate = new Date(new Date(dateTo).getTime()).toISOString();
queryParams["dateCreated[strictly_before]"] = toDate;
filenameParams.to = dateTo;
}
@@ -127,8 +129,8 @@
while (hasMoreData) {
const pageQueryParams = {
...queryParams,
itemsPerPage: "50",
page: currentPage.toString(),
itemsPerPage: 50,
page: currentPage,
};
const pageResponse = await apiGatewayChargesGetCollection({
@@ -208,6 +210,16 @@
isExporting = false;
}
}
$effect(() => {
if (typeof filters["checkout.gateway"] === "undefined") selectedPaymentMethod = "";
if (typeof filters.status === "undefined") selectedChargeStatus = "";
if (typeof filters["money.amount[gte]"] === "undefined") selectedRangeAmount = "";
if (typeof filters["money.amount[between]"] === "undefined") selectedRangeAmount = "";
if (typeof filters["dateCreated[after]"] === "undefined") dateFrom = "";
if (typeof filters["dateCreated[before]"] === "undefined") dateTo = "";
});
</script>
<div
@@ -224,7 +236,7 @@
>
<span class="relative">
<FiltersIcon />
{#if selectedPaymentMethod || selectedChargeStatus || selectedRangeAmount || dateFrom || dateTo}
{#if selectedPaymentMethod !== "" || selectedChargeStatus !== "" || selectedRangeAmount !== "" || dateFrom !== "" || dateTo !== ""}
<span class="absolute -top-1 -right-1">
<ActiveFilterIcon />
</span>
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts">
import CloseIcon from "../../svgs/CloseIcon.svelte";
import Tag from "../library/Tag.svelte";
import { locale } from "../../i18n/store";
import type { Locale } from "../../i18n/locales";
import { t } from "../../i18n/store";
import type { ApiGatewayChargesGetCollectionData } from "../../openapi/client";
import { formatDate } from "../../utils/dates";
let { title, filters, onCloseFilter } = $props<{
title: string;
filters: ApiGatewayChargesGetCollectionData["query"];
onCloseFilter: (filters: ApiGatewayChargesGetCollectionData["query"]) => void;
}>();
type FilterTag = { title: string; value?: string; values?: { from?: string; to?: string } };
type FilterTags = FilterTag[];
let tags: FilterTags | undefined = $state(undefined);
function closeTag(tag: FilterTag) {
if (tag.values?.from && tag.values?.to) {
onCloseFilter({
...filters,
["dateCreated[after]"]: undefined,
["dateCreated[before]"]: undefined,
});
} else {
onCloseFilter({
...filters,
[tag.title]: undefined,
});
}
}
function formatTags(tags: FilterTags, locale: Locale) {
if (tags === undefined) return;
tags.map((tag) => {
if (tag.values?.from && tag.values?.to) {
tag.values.from = formatDate(new Date(tag.values.from), locale);
tag.values.to = formatDate(new Date(tag.values.to), locale);
}
if (tag.title === "checkout.gateway") {
tag.value = $t(`contributions.filters.paymentMethod.options.${tag.value}`);
}
if (tag.title === "status") {
tag.value = $t(`contributions.filters.chargeStatus.options.${tag.value}`);
}
if (tag.title === "money.amount[between]") {
}
if (tag.title === "money.amount[gte]")
tag.value = $t(`contributions.filters.rangeAmount.options.${tag.value}`);
});
return tags;
}
$effect(() => {
if (filters !== undefined) {
let dateFrom = "dateCreated[after]";
let dateTo = "dateCreated[before]";
let normalTags: FilterTags | undefined = Object.keys(filters)
.map((filter) => {
if (filter === dateFrom || filter === dateTo) return { title: filter };
else return { title: filter, value: filters[filter] };
})
.filter((filter) => {
if (filter.title === dateFrom || filter.title === dateTo) return false;
if (filter.value === undefined) return filter.value !== undefined;
if (filter.value === "all") return false;
else return filter.value !== "";
});
if (
filters[dateFrom] !== "" &&
filters[dateTo] !== "" &&
typeof filters[dateFrom] !== "undefined" &&
typeof filters[dateTo] !== "undefined"
) {
normalTags = [
...normalTags,
{
title: "date",
values: {
from: filters[dateFrom],
to: filters[dateTo],
},
},
];
}
tags = formatTags([...normalTags], $locale);
}
});
</script>
<div class="flex gap-4">
<h1 class="text-2xl/8 font-bold text-black">
{title}
</h1>
{#each tags as tag}
<Tag variant={"bold"}>
{#if tag.values}
{`${tag.values.from} - ${tag.values.to}`}
{:else}
{tag.value}
{/if}
<button onclick={() => closeTag(tag)} class="size-auto cursor-pointer">
<CloseIcon class="size-[15px]" />
</button>
</Tag>
{/each}
</div>
+10 -18
View File
@@ -29,6 +29,7 @@
Accounting,
ApiProjectsIdOrSlugGetData,
ApiUsersIdGetData,
ApiGatewayChargesGetCollectionData,
} from "../../../src/openapi/client/index.ts";
import DetailsRow from "./DetailsRow.svelte";
import { client } from "../../openapi/client/client.gen.ts";
@@ -207,11 +208,11 @@
}
async function loadCharges(filters: {
chargeStatus: string;
rangeAmount: string;
from?: string;
to?: string;
paymentMethod: string;
chargeStatus: string;
rangeAmount?: string;
dateFrom?: string;
dateTo?: string;
target?: string;
}) {
const current = Number(itemsPerPage);
@@ -272,8 +273,8 @@
(filters.rangeAmount.includes("..")
? { "money.amount[between]": filters.rangeAmount }
: { "money.amount[gte]": filters.rangeAmount })),
...(filters.from && { "dateCreated[strictly_after]": filters.from }),
...(filters.to && { "dateCreated[strictly_before]": filters.to }),
...(filters.dateFrom && { "dateCreated[strictly_after]": filters.dateFrom }),
...(filters.dateTo && { "dateCreated[strictly_before]": filters.dateTo }),
...(filters.paymentMethod &&
filters.paymentMethod !== "all" && {
"checkout.gateway": `/v4/gateways/${filters.paymentMethod}`,
@@ -487,21 +488,12 @@
};
}
let { filters } = $props<{
filters: {
paymentMethod: string;
chargeStatus: string;
rangeAmount: string;
from?: string;
to?: string;
target?: string;
};
}>();
let { filters } = $props<ApiGatewayChargesGetCollectionData["query"]>();
$effect(() => {
const { chargeStatus, rangeAmount, from, to, paymentMethod, target } = filters;
const { paymentMethod, chargeStatus, rangeAmount, dateFrom, dateTo, target } = filters;
charges = [];
loadCharges({ chargeStatus, rangeAmount, from, to, paymentMethod, target });
loadCharges({ paymentMethod, chargeStatus, rangeAmount, dateFrom, dateTo, target });
});
</script>
+2 -2
View File
@@ -14,13 +14,13 @@
success: "bg-variant2",
warning: "bg-variant4",
error: "bg-tertiary",
bold: "bg-grey border-grey text-secondary font-bold",
bold: "bg-grey border-grey text-secondary font-bold border-soft-purple",
};
</script>
<div
class={twMerge(
"flex content-center items-center gap-2 rounded-[4px] border border-black bg-white px-2 py-1 whitespace-nowrap text-black",
"flex items-center justify-center gap-2 rounded-sm border border-black bg-white px-2 py-1 text-[14px] whitespace-nowrap text-black",
variant ? variantStyles[variant] : "",
classes,
)}
+10 -14
View File
@@ -9,6 +9,8 @@
import { onMount } from "svelte";
import type { User } from "../../openapi/client/types.gen.ts";
import { extractId } from "../../utils/extractId.ts";
import { locale } from "../../i18n/store";
import { formatDate } from "../../utils/dates.ts";
interface Props {
update: ProjectUpdate;
@@ -22,16 +24,6 @@
let cardClasses = $state("");
let author: User | undefined = $state(undefined);
function formatDate(date: string, locale?: string): string {
const options: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "short",
year: "numeric",
};
return new Date(date).toLocaleDateString(locale, options);
}
async function getAuthor(update: ProjectUpdate): Promise<User | undefined> {
const authorId: string | null = extractId(update.author);
if (!authorId) return undefined;
@@ -61,13 +53,15 @@
{#if type === "small"}
<div
class={twMerge(
"flex w-[34.625rem] flex-col gap-6 rounded-4xl bg-white p-6 opacity-48",
"flex w-138.5 flex-col gap-6 rounded-4xl bg-white p-6 opacity-48",
cardClasses,
)}
>
<div class="flex flex-col gap-4">
<div class="text-secondary flex flex-row gap-0.5 text-2xl font-bold">
{formatDate(update.date ?? "")}
{#if update.date}
{formatDate(new Date(update.date), $locale)}
{/if}
<div class="pt-1">
<ActiveFilterIcon />
</div>
@@ -80,7 +74,7 @@
{update.title}
</h2>
<svg
class="absolute -top-[19.25rem] left-[6.1875rem]"
class="absolute -top-77 left-24.75"
width="681"
height="964.575"
viewBox="0 0 407 533"
@@ -104,7 +98,9 @@
>
<div class="flex flex-col gap-4">
<div class="text-secondary flex flex-row gap-0.5 text-2xl font-bold">
{formatDate(update.date ?? "")}
{#if update.date}
{formatDate(new Date(update.date), $locale)}
{/if}
<div class="pt-1">
<ActiveFilterIcon />
</div>
+2 -4
View File
@@ -605,8 +605,7 @@
"wallet": "Moneder"
},
"status": {
"in_pending": "Pendent",
"charged": "Cobrat",
"in_charge": "Cobrat",
"to_refund": "Per reemborsar",
"refunded": "Reemborsat"
},
@@ -717,8 +716,7 @@
"title": "Estat de l'aportació",
"options": {
"all": "Tots",
"in_pending": "Pendent",
"charged": "Cobrat",
"in_charge": "Cobrat",
"to_refund": "Per reemborsar",
"refunded": "Reemborsat"
}
+1 -4
View File
@@ -597,8 +597,7 @@
"wallet": "Wallet"
},
"status": {
"in_pending": "Pending",
"charged": "Charged",
"in_charge": "Charged",
"to_refund": "To refund",
"refunded": "Refunded"
},
@@ -709,8 +708,6 @@
"title": "Charge status",
"options": {
"all": "All",
"in_pending": "Pending",
"charged": "Charged",
"to_refund": "To be refunded",
"refunded": "Refunded"
}
+13 -6
View File
@@ -601,10 +601,12 @@
"wallet": "Monedero"
},
"status": {
"in_pending": "Pendiente",
"charged": "Cobrado",
"to_charge": "Por cobrar",
"in_charge": "Cobrado",
"to_refund": "Por reembolsar",
"refunded": "Reembolsado"
"refunded": "Reembolsado",
"to_wallet": "Por reembolsar a monedero",
"walleted": "Reembolsado a monedero"
},
"refund": {
"to_wallet": "Sí",
@@ -713,10 +715,12 @@
"title": "Estado de Aporte",
"options": {
"all": "Todos",
"in_pending": "Pendiente",
"charged": "Cobrado",
"to_charge": "Por cobrar",
"in_charge": "Cobrado",
"to_refund": "Por reembolsar",
"refunded": "Reembolsado"
"refunded": "Reembolsado",
"to_wallet": "Por reembolsar a monedero",
"walleted": "Reembolsado a monedero"
}
},
"projectStatus": {
@@ -881,6 +885,9 @@
"totalPaid": "Total pagado:",
"totalUnpaid": "Falta por pagar:"
}
},
"charges": {
"lastContributions": "Últimos aportes:"
}
},
"footer": {
+15 -1
View File
@@ -1,4 +1,18 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
<script lang="ts">
import type { ClassNameValue } from "tailwind-merge";
interface Props {
class?: ClassNameValue;
}
let { class: classes }: Props = $props();
</script>
<svg
class={classes ? classes : ""}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"> </g>
<g id="SVGRepo_iconCarrier">

Before

Width:  |  Height:  |  Size: 594 B

After

Width:  |  Height:  |  Size: 837 B

+11
View File
@@ -0,0 +1,11 @@
import type { Locale } from "../i18n/locales";
export function formatDate(date: Date, locale: Locale) {
const options: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "short",
year: "numeric",
};
return date.toLocaleDateString(locale, options);
}