mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
* adding ability to tag listings eg if you have applied to it / adding ability to add notes to a listing * storing the date when a status was set
344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
/*
|
|
* Copyright (c) 2026 by Christian Kellner.
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
*/
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
useSearchParamState,
|
|
parseNumber,
|
|
parseString,
|
|
parseNullableBoolean,
|
|
} from '../../hooks/useSearchParamState.js';
|
|
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
|
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
|
import { xhrDelete, xhrPost } from '../../services/xhr.js';
|
|
import { useActions, useSelector } from '../../services/state/store.js';
|
|
import { debounce } from '../../utils';
|
|
import ListingsGrid from '../grid/listings/ListingsGrid.jsx';
|
|
import ListingsTable from '../table/ListingsTable.jsx';
|
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
|
|
import './ListingsOverview.less';
|
|
|
|
const ListingsOverview = ({ mode = 'all' }) => {
|
|
const isWatchlistMode = mode === 'watchlist';
|
|
const listingsData = useSelector((state) => state.listingsData);
|
|
const providers = useSelector((state) => state.provider);
|
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
|
const actions = useActions();
|
|
const navigate = useNavigate();
|
|
const sp = useSearchParams();
|
|
|
|
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
|
|
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
|
const pageSize = 40;
|
|
|
|
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
|
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
|
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
|
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
|
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
|
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
const [listingToDelete, setListingToDelete] = useState(null);
|
|
|
|
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
|
|
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
|
|
|
|
const loadData = () => {
|
|
actions.listingsData.getListingsData({
|
|
page,
|
|
pageSize,
|
|
sortfield: sortField,
|
|
sortdir: sortDir,
|
|
freeTextFilter,
|
|
filter: {
|
|
watchListFilter: effectiveWatchListFilter,
|
|
jobNameFilter,
|
|
activityFilter,
|
|
providerFilter,
|
|
statusFilter,
|
|
},
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [
|
|
page,
|
|
sortField,
|
|
sortDir,
|
|
freeTextFilter,
|
|
providerFilter,
|
|
activityFilter,
|
|
jobNameFilter,
|
|
watchListFilter,
|
|
statusFilter,
|
|
isWatchlistMode,
|
|
]);
|
|
|
|
const handleFilterChange = useMemo(
|
|
() =>
|
|
debounce((value) => {
|
|
setFreeTextFilter(value || null);
|
|
setPage(1);
|
|
}, 500),
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
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 handleStatusChange = async (item, nextStatus) => {
|
|
try {
|
|
await actions.listingsData.setListingStatus(item.id, nextStatus);
|
|
Toast.success(nextStatus ? `Marked as ${nextStatus}` : 'Status cleared');
|
|
loadData();
|
|
} catch (e) {
|
|
console.error(e);
|
|
Toast.error('Failed to update status');
|
|
}
|
|
};
|
|
|
|
const handleDelete = (id) => {
|
|
if (listingDeletionPref?.skipPrompt) {
|
|
confirmDeletion(listingDeletionPref.hardDelete, false, id);
|
|
return;
|
|
}
|
|
setListingToDelete(id);
|
|
setDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
|
|
|
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
|
try {
|
|
if (remember) {
|
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
|
}
|
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
|
Toast.success('Listing successfully removed');
|
|
loadData();
|
|
} catch (error) {
|
|
Toast.error(error.message || 'Error deleting listing');
|
|
} finally {
|
|
setDeleteModalVisible(false);
|
|
setListingToDelete(null);
|
|
}
|
|
};
|
|
|
|
const listings = listingsData?.result || [];
|
|
|
|
return (
|
|
<div className="listingsOverview">
|
|
<div className="listingsOverview__topbar">
|
|
<Input
|
|
className="listingsOverview__topbar__search"
|
|
prefix={<IconSearch />}
|
|
showClear
|
|
placeholder="Search"
|
|
defaultValue={freeTextFilter ?? ''}
|
|
onChange={handleFilterChange}
|
|
/>
|
|
|
|
<RadioGroup
|
|
type="button"
|
|
buttonSize="middle"
|
|
value={activityFilter === null ? 'all' : String(activityFilter)}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<Radio value="all">All</Radio>
|
|
<Radio value="true">Active</Radio>
|
|
<Radio value="false">Inactive</Radio>
|
|
</RadioGroup>
|
|
|
|
{!isWatchlistMode && (
|
|
<RadioGroup
|
|
type="button"
|
|
buttonSize="middle"
|
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
|
onChange={(e) => {
|
|
const v = e.target.value;
|
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<Radio value="all">All</Radio>
|
|
<Radio value="true">Watched</Radio>
|
|
<Radio value="false">Unwatched</Radio>
|
|
</RadioGroup>
|
|
)}
|
|
|
|
<Select
|
|
placeholder="Status"
|
|
showClear
|
|
onChange={(val) => {
|
|
setStatusFilter(val ?? null);
|
|
setPage(1);
|
|
}}
|
|
value={statusFilter}
|
|
style={{ width: 150 }}
|
|
>
|
|
<Select.Option value="applied">Applied</Select.Option>
|
|
<Select.Option value="rejected">Rejected</Select.Option>
|
|
<Select.Option value="accepted">Accepted</Select.Option>
|
|
<Select.Option value="none">No status</Select.Option>
|
|
</Select>
|
|
|
|
<Select
|
|
placeholder="Provider"
|
|
showClear
|
|
onChange={(val) => {
|
|
setProviderFilter(val);
|
|
setPage(1);
|
|
}}
|
|
value={providerFilter}
|
|
style={{ width: 130 }}
|
|
>
|
|
{providers?.map((p) => (
|
|
<Select.Option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
|
|
<Select
|
|
placeholder="Job"
|
|
showClear
|
|
onChange={(val) => {
|
|
setJobNameFilter(val);
|
|
setPage(1);
|
|
}}
|
|
value={jobNameFilter}
|
|
style={{ width: 130 }}
|
|
>
|
|
{jobs?.map((j) => (
|
|
<Select.Option key={j.id} value={j.id}>
|
|
{j.name}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
|
|
<Select
|
|
prefix="Sort by"
|
|
className="listingsOverview__topbar__sort"
|
|
style={{ width: 220 }}
|
|
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(sortDir === 'asc' ? 'desc' : 'asc')}
|
|
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
|
/>
|
|
|
|
<div className="listingsOverview__topbar__view-toggle">
|
|
<Tooltip content="Grid view">
|
|
<Button
|
|
icon={<IconGridView />}
|
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
|
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
|
aria-label="Grid view"
|
|
aria-pressed={viewMode === 'grid'}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip content="Table view">
|
|
<Button
|
|
icon={<IconList />}
|
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
|
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
|
aria-label="Table view"
|
|
aria-pressed={viewMode === 'table'}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
|
|
{listings.length === 0 && (
|
|
<Empty
|
|
image={<IllustrationNoResult />}
|
|
darkModeImage={<IllustrationNoResultDark />}
|
|
description="No listings available yet..."
|
|
/>
|
|
)}
|
|
|
|
{viewMode === 'grid' ? (
|
|
<ListingsGrid
|
|
listings={listings}
|
|
onWatch={handleWatch}
|
|
onNavigate={handleNavigate}
|
|
onDelete={handleDelete}
|
|
onStatusChange={handleStatusChange}
|
|
/>
|
|
) : (
|
|
<ListingsTable
|
|
listings={listings}
|
|
onWatch={handleWatch}
|
|
onNavigate={handleNavigate}
|
|
onDelete={handleDelete}
|
|
onStatusChange={handleStatusChange}
|
|
/>
|
|
)}
|
|
|
|
{listings.length > 0 && (
|
|
<div className="listingsOverview__pagination">
|
|
<Pagination
|
|
currentPage={page}
|
|
pageSize={pageSize}
|
|
total={listingsData?.totalNumber || 0}
|
|
onPageChange={setPage}
|
|
showSizeChanger={false}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<ListingDeletionModal
|
|
visible={deleteModalVisible}
|
|
defaultDeleteType={defaultDeleteType}
|
|
onConfirm={confirmDeletion}
|
|
onCancel={() => {
|
|
setDeleteModalVisible(false);
|
|
setListingToDelete(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ListingsOverview;
|