ability to restore (soft deleted) listings

This commit is contained in:
orangecoding
2026-06-11 08:24:26 +02:00
parent 3b727ea708
commit 3249881771
13 changed files with 438 additions and 134 deletions

View File

@@ -26,6 +26,7 @@ export default async function listingsPlugin(fastify) {
providerFilter, providerFilter,
watchListFilter, watchListFilter,
statusFilter, statusFilter,
hiddenOnly,
sortfield = null, sortfield = null,
sortdir = 'asc', sortdir = 'asc',
freeTextFilter, freeTextFilter,
@@ -38,6 +39,7 @@ export default async function listingsPlugin(fastify) {
}; };
const normalizedActivity = toBool(activityFilter); const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter); const normalizedWatch = toBool(watchListFilter);
const normalizedHidden = toBool(hiddenOnly) === true;
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none']; const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus = const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase()) typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
@@ -62,6 +64,7 @@ export default async function listingsPlugin(fastify) {
providerFilter, providerFilter,
watchListFilter: normalizedWatch, watchListFilter: normalizedWatch,
statusFilter: normalizedStatus, statusFilter: normalizedStatus,
hiddenOnly: normalizedHidden,
sortField: sortfield || null, sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc', sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser, userId: request.session.currentUser,
@@ -192,4 +195,21 @@ export default async function listingsPlugin(fastify) {
} }
return reply.send(); return reply.send();
}); });
fastify.post('/restore', async (request, reply) => {
const { ids } = request.body || {};
const settings = await getSettings();
try {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot restore listings in demo mode ;)' });
}
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.restoreListingsById(ids);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
} }

View File

@@ -264,6 +264,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms). * @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins). * @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
* @param {boolean} [params.isAdmin=false] - When true, returns all listings. * @param {boolean} [params.isAdmin=false] - When true, returns all listings.
* @param {boolean} [params.hiddenOnly=false] - When true, returns only soft-deleted (manually_deleted = 1) listings.
* @returns {{ totalNumber:number, page:number, result:Object[] }} * @returns {{ totalNumber:number, page:number, result:Object[] }}
*/ */
export const queryListings = ({ export const queryListings = ({
@@ -284,6 +285,7 @@ export const queryListings = ({
maxPrice = null, maxPrice = null,
userId = null, userId = null,
isAdmin = false, isAdmin = false,
hiddenOnly = false,
} = {}) => { } = {}) => {
// sanitize inputs // sanitize inputs
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50; const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
@@ -365,8 +367,8 @@ export const queryListings = ({
whereParts.push('(l.price <= @maxPrice)'); whereParts.push('(l.price <= @maxPrice)');
} }
// Build whereSql (filtering by manually_deleted = 0) // Build whereSql: in normal mode hide soft-deleted; in hiddenOnly mode show only soft-deleted.
whereParts.push('(l.manually_deleted = 0)'); whereParts.push(hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)');
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
@@ -463,6 +465,23 @@ export const deleteListingsById = (ids, hardDelete = false) => {
); );
}; };
/**
* Restore previously soft-deleted listings by clearing their `manually_deleted` flag.
*
* @param {string[]} ids - Array of DB row IDs to restore.
* @returns {any} The result from SqliteConnection.execute.
*/
export const restoreListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(
`UPDATE listings
SET manually_deleted = 0
WHERE id IN (${placeholders})`,
ids,
);
};
/** /**
* Return all listings that are active, have an address, and do not yet have geocoordinates. * Return all listings that are active, have an address, and do not yet have geocoordinates.
* *

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "22.7.0", "version": "22.8.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

View File

@@ -120,6 +120,57 @@ describe('listingsStorage.queryListings statusFilter', () => {
}); });
}); });
describe('listingsStorage.queryListings hiddenOnly', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
return [];
};
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('filters by manually_deleted = 0 by default', () => {
listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 0\)/);
});
it('filters by manually_deleted = 1 when hiddenOnly is true', () => {
listingsStorage.queryListings({ userId: 'u1', isAdmin: true, hiddenOnly: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 1\)/);
expect(pageQuery.sql).not.toMatch(/\(l\.manually_deleted = 0\)/);
});
});
describe('listingsStorage.restoreListingsById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('clears the manually_deleted flag for the given ids', () => {
listingsStorage.restoreListingsById(['a', 'b']);
expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings\s+SET manually_deleted = 0\s+WHERE id IN \(\?,\?\)/);
expect(calls.execute[0].params).toEqual(['a', 'b']);
});
it('is a no-op when ids are missing or empty', () => {
listingsStorage.restoreListingsById([]);
listingsStorage.restoreListingsById(undefined);
expect(calls.execute).toHaveLength(0);
});
});
describe('listingsStorage.getListingById', () => { describe('listingsStorage.getListingById', () => {
let listingsStorage; let listingsStorage;

View File

@@ -22,9 +22,9 @@ import './ListingsGrid.less';
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx'; import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/ */
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => { const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => {
const t = useTranslation(); const t = useTranslation();
const locale = useLocale(); const locale = useLocale();
return ( return (
@@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip content={t('listings.tooltipRemove')}> {isHiddenView ? (
<Button <Tooltip content={t('listings.tooltipUndelete')}>
size="small" <Button
icon={<IconDelete />} size="small"
style={{ color: '#fb7185' }} icon={
theme="borderless" <span className="listingsGrid__strike" aria-hidden="true">
onClick={(e) => { <IconDelete />
e.stopPropagation(); </span>
onDelete(item.id); }
}} style={{ color: '#34d399' }}
/> theme="borderless"
</Tooltip> onClick={(e) => {
e.stopPropagation();
onRestore?.(item.id);
}}
aria-label={t('listings.tooltipUndelete')}
/>
</Tooltip>
) : (
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -139,4 +139,23 @@
border-radius: @radius-chip !important; border-radius: @radius-chip !important;
} }
} }
&__strike {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&::after {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 50%;
height: 2px;
background: currentColor;
transform: rotate(-45deg);
pointer-events: none;
}
}
} }

View File

@@ -10,7 +10,18 @@ import {
parseString, parseString,
parseNullableBoolean, parseNullableBoolean,
} from '../../hooks/useSearchParamState.js'; } from '../../hooks/useSearchParamState.js';
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19'; import {
Button,
Pagination,
Toast,
Input,
Select,
Empty,
Radio,
RadioGroup,
Tooltip,
Banner,
} from '@douyinfe/semi-ui-19';
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons'; import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../ListingDeletionModal.jsx'; import ListingDeletionModal from '../ListingDeletionModal.jsx';
@@ -50,9 +61,12 @@ const ListingsOverview = ({ mode = 'all' }) => {
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean); const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString); const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString); const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
const [hiddenOnly, setHiddenOnly] = useSearchParamState(sp, 'hidden', false, parseNullableBoolean);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
const isHiddenView = hiddenOnly === true;
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL. // In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter; const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
@@ -66,9 +80,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
filter: { filter: {
watchListFilter: effectiveWatchListFilter, watchListFilter: effectiveWatchListFilter,
jobNameFilter, jobNameFilter,
activityFilter, activityFilter: isHiddenView ? null : activityFilter,
providerFilter, providerFilter,
statusFilter, statusFilter,
hiddenOnly: isHiddenView ? true : undefined,
}, },
}); });
}; };
@@ -85,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
jobNameFilter, jobNameFilter,
watchListFilter, watchListFilter,
statusFilter, statusFilter,
hiddenOnly,
isWatchlistMode, isWatchlistMode,
]); ]);
@@ -138,7 +154,21 @@ const ListingsOverview = ({ mode = 'all' }) => {
setDeleteModalVisible(true); setDeleteModalVisible(true);
}; };
const handleNavigate = (id) => navigate(`/listings/listing/${id}`); const handleRestore = async (id) => {
try {
await actions.listingsData.restoreListings([id]);
Toast.success(t('listings.toastRestored'));
loadData();
} catch (e) {
console.error(e);
Toast.error(t('listings.toastRestoreError'));
}
};
const handleNavigate = (id) => {
if (isHiddenView) return;
navigate(`/listings/listing/${id}`);
};
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => { const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
try { try {
@@ -158,118 +188,161 @@ const ListingsOverview = ({ mode = 'all' }) => {
const listings = listingsData?.result || []; const listings = listingsData?.result || [];
const activityRadioValue = isHiddenView ? 'hidden' : activityFilter === null ? 'all' : String(activityFilter);
return ( return (
<div className="listingsOverview"> <div className="listingsOverview">
<div className="listingsOverview__topbar"> <div className="listingsOverview__topbar">
<Input <Tooltip content={t('listings.filterSearchHelp')} trigger="hover" position="top">
className="listingsOverview__topbar__search" <span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__search">
prefix={<IconSearch />} <Input
showClear prefix={<IconSearch />}
placeholder={t('listings.searchPlaceholder')} showClear
defaultValue={freeTextFilter ?? ''} placeholder={t('listings.searchPlaceholder')}
onChange={handleFilterChange} defaultValue={freeTextFilter ?? ''}
/> onChange={handleFilterChange}
/>
</span>
</Tooltip>
<RadioGroup <Tooltip content={t('listings.filterActivityHelp')} trigger="hover" position="top">
type="button" <span className="listingsOverview__topbar__tooltipWrap">
buttonSize="middle" <RadioGroup
value={activityFilter === null ? 'all' : String(activityFilter)} type="button"
onChange={(e) => { buttonSize="middle"
const v = e.target.value; value={activityRadioValue}
setActivityFilter(v === 'all' ? null : v === 'true'); onChange={(e) => {
setPage(1); const v = e.target.value;
}} if (v === 'hidden') {
> setHiddenOnly(true);
<Radio value="all">{t('listings.filterAll')}</Radio> setActivityFilter(null);
<Radio value="true">{t('listings.filterActive')}</Radio> } else {
<Radio value="false">{t('listings.filterInactive')}</Radio> setHiddenOnly(false);
</RadioGroup> setActivityFilter(v === 'all' ? null : v === 'true');
}
setPage(1);
}}
>
<Radio value="all">{t('listings.filterAll')}</Radio>
<Radio value="true">{t('listings.filterActive')}</Radio>
<Radio value="false">{t('listings.filterInactive')}</Radio>
<Radio value="hidden">{t('listings.filterHidden')}</Radio>
</RadioGroup>
</span>
</Tooltip>
{!isWatchlistMode && ( {!isWatchlistMode && (
<RadioGroup <Tooltip content={t('listings.filterWatchHelp')} trigger="hover" position="top">
type="button" <span className="listingsOverview__topbar__tooltipWrap">
buttonSize="middle" <RadioGroup
value={watchListFilter === null ? 'all' : String(watchListFilter)} type="button"
onChange={(e) => { buttonSize="middle"
const v = e.target.value; value={watchListFilter === null ? 'all' : String(watchListFilter)}
setWatchListFilter(v === 'all' ? null : v === 'true'); onChange={(e) => {
setPage(1); const v = e.target.value;
}} setWatchListFilter(v === 'all' ? null : v === 'true');
> setPage(1);
<Radio value="all">{t('listings.filterAll')}</Radio> }}
<Radio value="true">{t('listings.filterWatched')}</Radio> >
<Radio value="false">{t('listings.filterUnwatched')}</Radio> <Radio value="all">{t('listings.filterAll')}</Radio>
</RadioGroup> <Radio value="true">{t('listings.filterWatched')}</Radio>
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
</RadioGroup>
</span>
</Tooltip>
)} )}
<Select <Tooltip content={t('listings.filterStatusHelp')} trigger="hover" position="top">
placeholder={t('listings.filterStatusPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterStatusPlaceholder')}
setStatusFilter(val ?? null); showClear
setPage(1); onChange={(val) => {
}} setStatusFilter(val ?? null);
value={statusFilter} setPage(1);
style={{ width: 150 }} }}
> value={statusFilter}
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option> style={{ width: 150 }}
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option> >
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option> <Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option> <Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
</Select> <Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterProviderHelp')} trigger="hover" position="top">
placeholder={t('listings.filterProviderPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterProviderPlaceholder')}
setProviderFilter(val); showClear
setPage(1); onChange={(val) => {
}} setProviderFilter(val);
value={providerFilter} setPage(1);
style={{ width: 130 }} }}
> value={providerFilter}
{providers?.map((p) => ( style={{ width: 130 }}
<Select.Option key={p.id} value={p.id}> >
{p.name} {providers?.map((p) => (
</Select.Option> <Select.Option key={p.id} value={p.id}>
))} {p.name}
</Select> </Select.Option>
))}
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterJobHelp')} trigger="hover" position="top">
placeholder={t('listings.filterJobPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterJobPlaceholder')}
setJobNameFilter(val); showClear
setPage(1); onChange={(val) => {
}} setJobNameFilter(val);
value={jobNameFilter} setPage(1);
style={{ width: 130 }} }}
> value={jobNameFilter}
{jobs?.map((j) => ( style={{ width: 130 }}
<Select.Option key={j.id} value={j.id}> >
{j.name} {jobs?.map((j) => (
</Select.Option> <Select.Option key={j.id} value={j.id}>
))} {j.name}
</Select> </Select.Option>
))}
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterSortHelp')} trigger="hover" position="top">
prefix={t('listings.sortPrefix')} <span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__sort">
className="listingsOverview__topbar__sort" <Select
style={{ width: 220 }} prefix={t('listings.sortPrefix')}
value={sortField} style={{ width: 220 }}
onChange={(val) => setSortField(val)} value={sortField}
> onChange={(val) => setSortField(val)}
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option> >
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option> <Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option> <Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option> <Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
</Select> <Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
</Select>
</span>
</Tooltip>
<Button <Tooltip
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />} content={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')} trigger="hover"
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')} position="top"
/> >
<span className="listingsOverview__topbar__tooltipWrap">
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
aria-label={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
/>
</span>
</Tooltip>
<div className="listingsOverview__topbar__view-toggle"> <div className="listingsOverview__topbar__view-toggle">
<Tooltip content={t('listings.tooltipGridView')}> <Tooltip content={t('listings.tooltipGridView')}>
@@ -293,6 +366,16 @@ const ListingsOverview = ({ mode = 'all' }) => {
</div> </div>
</div> </div>
{isHiddenView && (
<Banner
type="info"
fullMode={false}
closeIcon={null}
description={t('listings.hiddenViewBanner')}
style={{ marginBottom: 12 }}
/>
)}
{listings.length === 0 && ( {listings.length === 0 && (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
@@ -307,6 +390,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch} onWatch={handleWatch}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onDelete={handleDelete} onDelete={handleDelete}
onRestore={handleRestore}
isHiddenView={isHiddenView}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
/> />
) : ( ) : (
@@ -315,6 +400,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch} onWatch={handleWatch}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onDelete={handleDelete} onDelete={handleDelete}
onRestore={handleRestore}
isHiddenView={isHiddenView}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
/> />
)} )}

View File

@@ -8,6 +8,15 @@
margin-bottom: @space-4; margin-bottom: @space-4;
flex-wrap: wrap; flex-wrap: wrap;
&__tooltipWrap {
display: inline-flex;
align-items: center;
> * {
width: 100%;
}
}
&__search { &__search {
min-width: 200px; min-width: 200px;
flex: 1; flex: 1;

View File

@@ -22,9 +22,17 @@ import './ListingsTable.less';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx'; import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/ */
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => { const ListingsTable = ({
listings,
onWatch,
onNavigate,
onDelete,
onRestore,
isHiddenView = false,
onStatusChange,
}) => {
const t = useTranslation(); const t = useTranslation();
const locale = useLocale(); const locale = useLocale();
return ( return (
@@ -123,18 +131,38 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip content={t('listings.tooltipRemove')}> {isHiddenView ? (
<Button <Tooltip content={t('listings.tooltipUndelete')}>
size="small" <Button
icon={<IconDelete />} size="small"
style={{ color: '#fb7185' }} icon={
theme="borderless" <span className="listingsTable__strike" aria-hidden="true">
onClick={(e) => { <IconDelete />
e.stopPropagation(); </span>
onDelete(item.id); }
}} style={{ color: '#34d399' }}
/> theme="borderless"
</Tooltip> onClick={(e) => {
e.stopPropagation();
onRestore?.(item.id);
}}
aria-label={t('listings.tooltipUndelete')}
/>
</Tooltip>
) : (
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -5,6 +5,25 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&__strike {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&::after {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 50%;
height: 2px;
background: currentColor;
transform: rotate(-45deg);
pointer-events: none;
}
}
&__row { &__row {
display: grid; display: grid;
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto; grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;

View File

@@ -135,6 +135,7 @@
"listings.filterAll": "Alle", "listings.filterAll": "Alle",
"listings.filterActive": "Aktiv", "listings.filterActive": "Aktiv",
"listings.filterInactive": "Inaktiv", "listings.filterInactive": "Inaktiv",
"listings.filterHidden": "Versteckt",
"listings.filterWatched": "Beobachtet", "listings.filterWatched": "Beobachtet",
"listings.filterUnwatched": "Nicht beobachtet", "listings.filterUnwatched": "Nicht beobachtet",
"listings.filterStatusPlaceholder": "Status", "listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "Kein Status", "listings.filterStatusNone": "Kein Status",
"listings.filterProviderPlaceholder": "Anbieter", "listings.filterProviderPlaceholder": "Anbieter",
"listings.filterJobPlaceholder": "Job", "listings.filterJobPlaceholder": "Job",
"listings.filterSearchHelp": "Volltextsuche über Titel, Adresse, Anbieter und Link.",
"listings.filterActivityHelp": "Filtert nach Inseratsstatus: 'Alle' zeigt jedes Inserat, 'Aktiv' nur noch online verfügbare, 'Inaktiv' beim Anbieter verschwundene, 'Versteckt' zeigt deine manuell gelöschten (soft-deleted) Inserate, damit du sie wiederherstellen kannst.",
"listings.filterWatchHelp": "Filtert nach Watchlist-Zugehörigkeit: 'Alle' zeigt jedes Inserat, 'Beobachtet' nur die auf deiner Watchlist gespeicherten, 'Nicht beobachtet' die anderen.",
"listings.filterStatusHelp": "Filtert nach dem persönlichen Status (Beworben, Abgelehnt, Angenommen) oder zeigt nur Inserate ohne Status.",
"listings.filterProviderHelp": "Zeigt nur Inserate des ausgewählten Anbieters (ImmoScout24, Kleinanzeigen, ...).",
"listings.filterJobHelp": "Zeigt nur Inserate des ausgewählten Jobs.",
"listings.filterSortHelp": "Wählt das Sortierkriterium. Mit dem Pfeil-Button schaltet man zwischen aufsteigend und absteigend.",
"listings.hiddenViewBanner": "Du siehst gerade versteckte (soft-gelöschte) Inserate. Sie werden in den normalen Ansichten ausgeblendet. Über den Wiederherstellen-Button kannst du sie zurückholen.",
"listings.toastRestored": "Inserat wiederhergestellt",
"listings.toastRestoreError": "Wiederherstellung fehlgeschlagen",
"listings.tooltipUndelete": "Inserat wiederherstellen",
"listings.sortByJobName": "Job-Name", "listings.sortByJobName": "Job-Name",
"listings.sortByDate": "Inserat-Datum", "listings.sortByDate": "Inserat-Datum",
"listings.sortByPrice": "Preis", "listings.sortByPrice": "Preis",

View File

@@ -135,6 +135,7 @@
"listings.filterAll": "All", "listings.filterAll": "All",
"listings.filterActive": "Active", "listings.filterActive": "Active",
"listings.filterInactive": "Inactive", "listings.filterInactive": "Inactive",
"listings.filterHidden": "Hidden",
"listings.filterWatched": "Watched", "listings.filterWatched": "Watched",
"listings.filterUnwatched": "Unwatched", "listings.filterUnwatched": "Unwatched",
"listings.filterStatusPlaceholder": "Status", "listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "No status", "listings.filterStatusNone": "No status",
"listings.filterProviderPlaceholder": "Provider", "listings.filterProviderPlaceholder": "Provider",
"listings.filterJobPlaceholder": "Job", "listings.filterJobPlaceholder": "Job",
"listings.filterSearchHelp": "Free-text search across title, address, provider and link.",
"listings.filterActivityHelp": "Filter by listing activity: All shows every listing, Active only those still online, Inactive those that disappeared from the provider, Hidden shows your manually deleted (soft-deleted) listings so you can restore them.",
"listings.filterWatchHelp": "Filter by watchlist membership: All shows every listing, Watched only those you saved to your watchlist, Unwatched only those you have not saved.",
"listings.filterStatusHelp": "Filter by the personal status you set on a listing (Applied, Rejected, Accepted) or show only listings with no status yet.",
"listings.filterProviderHelp": "Show only listings coming from the selected real-estate provider (ImmoScout24, Kleinanzeigen, ...).",
"listings.filterJobHelp": "Show only listings produced by the selected job.",
"listings.filterSortHelp": "Choose the column to sort listings by. Use the arrow button to toggle ascending and descending order.",
"listings.hiddenViewBanner": "You are viewing hidden (soft-deleted) listings. They are excluded from the regular views. Use the restore button on a card to bring it back.",
"listings.toastRestored": "Listing restored",
"listings.toastRestoreError": "Failed to restore listing",
"listings.tooltipUndelete": "Restore Listing",
"listings.sortByJobName": "Job Name", "listings.sortByJobName": "Job Name",
"listings.sortByDate": "Listing Date", "listings.sortByDate": "Listing Date",
"listings.sortByPrice": "Price", "listings.sortByPrice": "Price",

View File

@@ -276,6 +276,14 @@ export const useFredyState = create(
throw Exception; throw Exception;
} }
}, },
async restoreListings(ids) {
try {
await xhrPost('/api/listings/restore', { ids });
} catch (Exception) {
console.error('Error while trying to restore listings. Error:', Exception);
throw Exception;
}
},
}, },
userSettings: { userSettings: {
async getUserSettings() { async getUserSettings() {