2025-12-23 08:47:51 +01:00
|
|
|
/*
|
2026-01-12 15:00:36 +01:00
|
|
|
* Copyright (c) 2026 by Christian Kellner.
|
2025-12-23 08:47:51 +01:00
|
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-16 13:50:50 +01:00
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
2025-12-23 08:47:51 +01:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
Col,
|
|
|
|
|
Row,
|
|
|
|
|
Image,
|
|
|
|
|
Button,
|
|
|
|
|
Typography,
|
|
|
|
|
Pagination,
|
|
|
|
|
Toast,
|
|
|
|
|
Divider,
|
|
|
|
|
Input,
|
|
|
|
|
Select,
|
|
|
|
|
Empty,
|
2026-03-23 13:22:34 +01:00
|
|
|
Radio,
|
|
|
|
|
RadioGroup,
|
2026-01-22 16:09:36 +01:00
|
|
|
} from '@douyinfe/semi-ui-19';
|
2025-12-23 08:47:51 +01:00
|
|
|
import {
|
|
|
|
|
IconBriefcase,
|
|
|
|
|
IconCart,
|
|
|
|
|
IconClock,
|
|
|
|
|
IconDelete,
|
|
|
|
|
IconLink,
|
|
|
|
|
IconMapPin,
|
|
|
|
|
IconStar,
|
|
|
|
|
IconStarStroked,
|
|
|
|
|
IconSearch,
|
2026-01-22 16:09:36 +01:00
|
|
|
IconActivity,
|
2026-01-28 14:27:03 +01:00
|
|
|
IconEyeOpened,
|
2026-03-23 13:22:34 +01:00
|
|
|
IconArrowUp,
|
|
|
|
|
IconArrowDown,
|
2025-12-23 08:47:51 +01:00
|
|
|
} from '@douyinfe/semi-icons';
|
2026-01-28 14:27:03 +01:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-02-03 14:04:40 +01:00
|
|
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
2025-12-23 08:47:51 +01:00
|
|
|
import no_image from '../../../assets/no_image.jpg';
|
|
|
|
|
import * as timeService from '../../../services/time/timeService.js';
|
|
|
|
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
|
|
|
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
|
|
|
|
import debounce from 'lodash/debounce';
|
|
|
|
|
|
|
|
|
|
import './ListingsGrid.less';
|
|
|
|
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
|
|
|
|
|
|
|
|
const { Text } = Typography;
|
|
|
|
|
|
|
|
|
|
const ListingsGrid = () => {
|
|
|
|
|
const listingsData = useSelector((state) => state.listingsData);
|
|
|
|
|
const providers = useSelector((state) => state.provider);
|
|
|
|
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
|
|
|
|
const actions = useActions();
|
2026-01-28 14:27:03 +01:00
|
|
|
const navigate = useNavigate();
|
2025-12-23 08:47:51 +01:00
|
|
|
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
const pageSize = 40;
|
|
|
|
|
|
|
|
|
|
const [sortField, setSortField] = useState('created_at');
|
|
|
|
|
const [sortDir, setSortDir] = useState('desc');
|
|
|
|
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
|
|
|
|
const [watchListFilter, setWatchListFilter] = useState(null);
|
|
|
|
|
const [jobNameFilter, setJobNameFilter] = useState(null);
|
|
|
|
|
const [activityFilter, setActivityFilter] = useState(null);
|
|
|
|
|
const [providerFilter, setProviderFilter] = useState(null);
|
2026-02-03 14:04:40 +01:00
|
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
|
|
|
const [listingToDelete, setListingToDelete] = useState(null);
|
|
|
|
|
|
2025-12-23 08:47:51 +01:00
|
|
|
const loadData = () => {
|
|
|
|
|
actions.listingsData.getListingsData({
|
|
|
|
|
page,
|
|
|
|
|
pageSize,
|
|
|
|
|
sortfield: sortField,
|
|
|
|
|
sortdir: sortDir,
|
|
|
|
|
freeTextFilter,
|
|
|
|
|
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadData();
|
|
|
|
|
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
|
|
|
|
|
|
|
|
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
// cleanup debounced handler to avoid memory leaks
|
|
|
|
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
|
|
|
|
};
|
|
|
|
|
}, [handleFilterChange]);
|
|
|
|
|
|
|
|
|
|
const handleWatch = async (e, item) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
try {
|
|
|
|
|
await xhrPost('/api/listings/watch', { listingId: item.id });
|
|
|
|
|
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
|
|
|
|
loadData();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
Toast.error('Failed to operate Watchlist');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePageChange = (_page) => {
|
|
|
|
|
setPage(_page);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 14:04:40 +01:00
|
|
|
const confirmDeletion = async (hardDelete) => {
|
|
|
|
|
try {
|
|
|
|
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
|
|
|
|
Toast.success('Listing successfully removed');
|
|
|
|
|
loadData();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
Toast.error(error.message || 'Error deleting listing');
|
|
|
|
|
} finally {
|
|
|
|
|
setDeleteModalVisible(false);
|
|
|
|
|
setListingToDelete(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-26 10:43:38 +01:00
|
|
|
const cap = (val) => {
|
|
|
|
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-23 08:47:51 +01:00
|
|
|
return (
|
|
|
|
|
<div className="listingsGrid">
|
2026-03-23 13:22:34 +01:00
|
|
|
<div className="listingsGrid__topbar">
|
|
|
|
|
<Input
|
|
|
|
|
className="listingsGrid__topbar__search"
|
|
|
|
|
prefix={<IconSearch />}
|
|
|
|
|
showClear
|
|
|
|
|
placeholder="Search"
|
|
|
|
|
onChange={handleFilterChange}
|
|
|
|
|
/>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
2026-03-23 13:22:34 +01:00
|
|
|
<RadioGroup
|
|
|
|
|
type="button"
|
|
|
|
|
buttonSize="middle"
|
|
|
|
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const v = e.target.value;
|
|
|
|
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Radio value="all">All</Radio>
|
|
|
|
|
<Radio value="true">Active</Radio>
|
|
|
|
|
<Radio value="false">Inactive</Radio>
|
|
|
|
|
</RadioGroup>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
2026-03-23 13:22:34 +01:00
|
|
|
<RadioGroup
|
|
|
|
|
type="button"
|
|
|
|
|
buttonSize="middle"
|
|
|
|
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const v = e.target.value;
|
|
|
|
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Radio value="all">All</Radio>
|
|
|
|
|
<Radio value="true">Watched</Radio>
|
|
|
|
|
<Radio value="false">Unwatched</Radio>
|
|
|
|
|
</RadioGroup>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
2026-03-23 13:22:34 +01:00
|
|
|
<Select
|
|
|
|
|
placeholder="Provider"
|
|
|
|
|
showClear
|
|
|
|
|
onChange={(val) => setProviderFilter(val)}
|
|
|
|
|
value={providerFilter}
|
|
|
|
|
style={{ width: 130 }}
|
|
|
|
|
>
|
|
|
|
|
{providers?.map((p) => (
|
|
|
|
|
<Select.Option key={p.id} value={p.id}>
|
|
|
|
|
{p.name}
|
|
|
|
|
</Select.Option>
|
|
|
|
|
))}
|
|
|
|
|
</Select>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
2026-03-23 13:22:34 +01:00
|
|
|
<Select
|
|
|
|
|
placeholder="Job"
|
|
|
|
|
showClear
|
|
|
|
|
onChange={(val) => setJobNameFilter(val)}
|
|
|
|
|
value={jobNameFilter}
|
|
|
|
|
style={{ width: 130 }}
|
|
|
|
|
>
|
|
|
|
|
{jobs?.map((j) => (
|
|
|
|
|
<Select.Option key={j.id} value={j.id}>
|
|
|
|
|
{j.name}
|
|
|
|
|
</Select.Option>
|
|
|
|
|
))}
|
|
|
|
|
</Select>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
2026-03-23 13:22:34 +01:00
|
|
|
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
|
|
|
|
|
<Select.Option value="job_name">Job Name</Select.Option>
|
|
|
|
|
<Select.Option value="created_at">Listing Date</Select.Option>
|
|
|
|
|
<Select.Option value="price">Price</Select.Option>
|
|
|
|
|
<Select.Option value="provider">Provider</Select.Option>
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
|
|
|
|
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
|
|
|
|
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-12-23 08:47:51 +01:00
|
|
|
|
|
|
|
|
{(listingsData?.result || []).length === 0 && (
|
|
|
|
|
<Empty
|
|
|
|
|
image={<IllustrationNoResult />}
|
|
|
|
|
darkModeImage={<IllustrationNoResultDark />}
|
|
|
|
|
description="No listings available yet..."
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<Row gutter={[16, 16]}>
|
|
|
|
|
{(listingsData?.result || []).map((item) => (
|
2026-03-23 13:22:34 +01:00
|
|
|
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
|
2025-12-23 08:47:51 +01:00
|
|
|
<Card
|
|
|
|
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
2026-01-28 14:27:03 +01:00
|
|
|
style={{ cursor: 'pointer' }}
|
|
|
|
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
2025-12-23 08:47:51 +01:00
|
|
|
cover={
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<div className="listingsGrid__imageContainer">
|
|
|
|
|
<Image
|
|
|
|
|
src={item.image_url || no_image}
|
|
|
|
|
fallback={no_image}
|
|
|
|
|
width="100%"
|
|
|
|
|
height={180}
|
|
|
|
|
style={{ objectFit: 'cover' }}
|
|
|
|
|
preview={false}
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
icon={
|
|
|
|
|
item.isWatched === 1 ? (
|
|
|
|
|
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
|
|
|
|
) : (
|
|
|
|
|
<IconStarStroked />
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
theme="light"
|
|
|
|
|
shape="circle"
|
|
|
|
|
size="small"
|
|
|
|
|
className="listingsGrid__watchButton"
|
|
|
|
|
onClick={(e) => handleWatch(e, item)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
bodyStyle={{ padding: '12px' }}
|
|
|
|
|
>
|
|
|
|
|
<div className="listingsGrid__content">
|
2026-01-26 10:43:38 +01:00
|
|
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
|
|
|
|
{cap(item.title)}
|
|
|
|
|
</Text>
|
2026-03-23 13:22:34 +01:00
|
|
|
<div className="listingsGrid__price">
|
|
|
|
|
<IconCart size="small" />
|
|
|
|
|
{item.price} €
|
|
|
|
|
</div>
|
|
|
|
|
<div className="listingsGrid__meta">
|
2025-12-23 08:47:51 +01:00
|
|
|
<Text
|
|
|
|
|
type="secondary"
|
|
|
|
|
icon={<IconMapPin />}
|
|
|
|
|
size="small"
|
|
|
|
|
ellipsis={{ showTooltip: true }}
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
>
|
|
|
|
|
{item.address || 'No address provided'}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text type="tertiary" size="small" icon={<IconClock />}>
|
|
|
|
|
{timeService.format(item.created_at, false)}
|
|
|
|
|
</Text>
|
|
|
|
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
|
|
|
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
|
|
|
|
</Text>
|
2026-01-22 16:09:36 +01:00
|
|
|
{item.distance_to_destination ? (
|
|
|
|
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
|
|
|
|
{item.distance_to_destination} m to chosen address
|
|
|
|
|
</Text>
|
|
|
|
|
) : (
|
|
|
|
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
2026-03-23 13:22:34 +01:00
|
|
|
Distance cannot be calculated
|
2026-01-22 16:09:36 +01:00
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-03-23 13:22:34 +01:00
|
|
|
</div>
|
2025-12-23 08:47:51 +01:00
|
|
|
<Divider margin=".6rem" />
|
2026-03-23 13:22:34 +01:00
|
|
|
<div className="listingsGrid__actions">
|
2026-01-28 14:27:03 +01:00
|
|
|
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
2026-01-26 10:43:38 +01:00
|
|
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
<IconLink />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
2026-01-28 14:27:03 +01:00
|
|
|
<Button
|
|
|
|
|
type="secondary"
|
|
|
|
|
size="small"
|
|
|
|
|
title="View Details"
|
|
|
|
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
|
|
|
|
icon={<IconEyeOpened />}
|
|
|
|
|
/>
|
2025-12-23 08:47:51 +01:00
|
|
|
<Button
|
|
|
|
|
title="Remove"
|
|
|
|
|
type="danger"
|
|
|
|
|
size="small"
|
2026-02-03 14:04:40 +01:00
|
|
|
onClick={(e) => {
|
2026-01-28 14:27:03 +01:00
|
|
|
e.stopPropagation();
|
2026-02-03 14:04:40 +01:00
|
|
|
setListingToDelete(item.id);
|
|
|
|
|
setDeleteModalVisible(true);
|
2025-12-23 08:47:51 +01:00
|
|
|
}}
|
|
|
|
|
icon={<IconDelete />}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</Col>
|
|
|
|
|
))}
|
|
|
|
|
</Row>
|
|
|
|
|
{(listingsData?.result || []).length > 0 && (
|
|
|
|
|
<div className="listingsGrid__pagination">
|
|
|
|
|
<Pagination
|
|
|
|
|
currentPage={page}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
total={listingsData?.totalNumber || 0}
|
|
|
|
|
onPageChange={handlePageChange}
|
|
|
|
|
showSizeChanger={false}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-03 14:04:40 +01:00
|
|
|
<ListingDeletionModal
|
|
|
|
|
visible={deleteModalVisible}
|
|
|
|
|
onConfirm={confirmDeletion}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setDeleteModalVisible(false);
|
|
|
|
|
setListingToDelete(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2025-12-23 08:47:51 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ListingsGrid;
|