Improved Listing Management (#317)

* 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
This commit is contained in:
Christian Kellner
2026-06-02 21:10:08 +02:00
committed by GitHub
parent 5ceac25aa6
commit 44edf47393
25 changed files with 891 additions and 58 deletions

View File

@@ -16,13 +16,14 @@ import {
} from '@douyinfe/semi-icons';
import no_image from '../../../assets/no_image.png';
import * as timeService from '../../../services/time/timeService.js';
import StatusControl from '../../listings/StatusControl.jsx';
import './ListingsGrid.less';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
<div className="listingsGrid__grid">
{listings.map((item) => (
<div
@@ -49,14 +50,16 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
<span>Inactive</span>
</div>
)}
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
</div>
<div className="listingsGrid__card__body">
@@ -83,6 +86,12 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
</div>
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<StatusControl
status={item.status?.status ?? null}
compact
onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()}
/>
<Tooltip content="Original Listing">
<Button
size="small"

View File

@@ -11,12 +11,11 @@
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
overflow: hidden;
transition: transform @transition-card, box-shadow @transition-card;
transition: box-shadow @transition-card;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
}

View File

@@ -23,7 +23,8 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
import './ListingsOverview.less';
const ListingsOverview = () => {
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);
@@ -46,9 +47,13 @@ const ListingsOverview = () => {
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,
@@ -56,13 +61,30 @@ const ListingsOverview = () => {
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
filter: {
watchListFilter: effectiveWatchListFilter,
jobNameFilter,
activityFilter,
providerFilter,
statusFilter,
},
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
}, [
page,
sortField,
sortDir,
freeTextFilter,
providerFilter,
activityFilter,
jobNameFilter,
watchListFilter,
statusFilter,
isWatchlistMode,
]);
const handleFilterChange = useMemo(
() =>
@@ -92,6 +114,17 @@ const ListingsOverview = () => {
}
};
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);
@@ -148,20 +181,38 @@ const ListingsOverview = () => {
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
{!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 }}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<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"
@@ -197,7 +248,13 @@ const ListingsOverview = () => {
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<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>
@@ -241,9 +298,21 @@ const ListingsOverview = () => {
)}
{viewMode === 'grid' ? (
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
<ListingsGrid
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
) : (
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
<ListingsTable
listings={listings}
onWatch={handleWatch}
onNavigate={handleNavigate}
onDelete={handleDelete}
onStatusChange={handleStatusChange}
/>
)}
{listings.length > 0 && (

View File

@@ -19,6 +19,14 @@
flex-shrink: 0;
}
&__sort {
flex-shrink: 0;
.semi-select-prefix {
white-space: nowrap;
}
}
@media (max-width: 768px) {
.listingsOverview__topbar__search {
width: 100%;

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useState } from 'react';
import { Dropdown, Button, Tooltip } from '@douyinfe/semi-ui-19';
import { IconChevronDown } from '@douyinfe/semi-icons';
import './StatusControl.less';
const STATUS_TOOLTIP =
'Track where you stand with this listing: Applied once you have reached out, Rejected if it did not work out, or Accepted if you got it.';
/**
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
*/
const STATUS_OPTIONS = [
{ value: null, label: 'None' },
{ value: 'applied', label: 'Applied' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'accepted', label: 'Accepted' },
];
/**
* Look up the option metadata for a status value.
* @param {ListingStatus} status
*/
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
/**
* Shared control for setting a listing's user-decision status
* (Applied / Rejected / Accepted).
*
* Both compact (table/grid rows) and full (listing detail header) modes
* render a Button that picks up the project's CI tokens via the
* .status-btn classes, with a small size variant for compact contexts.
*
* @param {Object} props
* @param {ListingStatus} props.status - The current status value.
* @param {(next: ListingStatus) => void} props.onChange - Called with the new status when the user picks one.
* @param {boolean} [props.compact=false] - When true, renders smaller for table/grid rows; full size otherwise.
* @param {(e: React.MouseEvent) => void} [props.onTriggerClick] - Optional click handler to stop propagation on the trigger.
*/
export default function StatusControl({ status = null, onChange, compact = false, onTriggerClick }) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const current = optionFor(status);
const handlePick = (next) => {
setOpen(false);
if (next === status) return;
onChange?.(next);
};
const menu = (
<Dropdown.Menu>
{STATUS_OPTIONS.map((opt) => (
<Dropdown.Item
key={opt.value ?? '__none__'}
active={opt.value === status}
onClick={() => handlePick(opt.value)}
>
{opt.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
);
const className = ['status-btn', compact ? 'status-btn--compact' : null, status ? `status-btn--${status}` : null]
.filter(Boolean)
.join(' ');
const trigger = (
<Tooltip
content={STATUS_TOOLTIP}
position="top"
trigger="custom"
visible={tooltipOpen && !open}
onVisibleChange={setTooltipOpen}
>
<Button
size={compact ? 'small' : 'default'}
theme="borderless"
icon={<IconChevronDown />}
iconPosition="right"
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
onClick={(e) => {
onTriggerClick?.(e);
setTooltipOpen(false);
setOpen((o) => !o);
}}
className={className}
>
{status ? current.label : 'Status'}
</Button>
</Tooltip>
);
return (
<Dropdown
trigger="custom"
visible={open}
onVisibleChange={setOpen}
onClickOutSide={() => setOpen(false)}
position="bottom"
render={menu}
stopPropagation
>
<span className="status-btn__anchor">{trigger}</span>
</Dropdown>
);
}

View File

@@ -0,0 +1,64 @@
@import '../../tokens.less';
// Wrapper span used as the Dropdown's positioning anchor so the menu opens
// directly below the visible button rather than the implicit wrapper of the
// hover tooltip (which can have a different bounding box).
.status-btn__anchor {
display: inline-block;
line-height: 0;
}
// StatusControl shared base. Matches dimensions and border treatment
// of the surrounding Watched / Open listing / Delete buttons in the
// detail view, and shrinks via the --compact modifier for table rows
// and grid cards.
.status-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-btn !important;
background: transparent !important;
transition: color @transition-fast, border-color @transition-fast, background @transition-fast;
&:hover {
color: @color-text !important;
background: rgba(255, 255, 255, 0.06) !important;
}
&--compact {
height: 24px !important;
padding: 0 8px !important;
font-size: @text-sm !important;
border-radius: @radius-chip !important;
.semi-icon {
font-size: 12px !important;
}
}
&--applied {
color: @color-info !important;
border-color: rgba(96, 165, 250, 0.4) !important;
background: rgba(96, 165, 250, 0.08) !important;
&:hover {
background: rgba(96, 165, 250, 0.14) !important;
}
}
&--rejected {
color: @color-error !important;
border-color: rgba(251, 113, 133, 0.4) !important;
background: rgba(251, 113, 133, 0.08) !important;
&:hover {
background: rgba(251, 113, 133, 0.14) !important;
}
}
&--accepted {
color: @color-success !important;
border-color: rgba(52, 211, 153, 0.4) !important;
background: rgba(52, 211, 153, 0.08) !important;
&:hover {
background: rgba(52, 211, 153, 0.14) !important;
}
}
}

View File

@@ -37,6 +37,7 @@ export default function Navigation({ isAdmin }) {
items: [
{ itemKey: '/listings', text: 'Overview' },
{ itemKey: '/map', text: 'Map View' },
{ itemKey: '/listings/watchlist', text: 'Watchlist' },
],
},
];
@@ -61,6 +62,22 @@ export default function Navigation({ isAdmin }) {
}
function parsePathName(name) {
// Collect every leaf itemKey that looks like a route (starts with '/').
// Prefer the longest exact-prefix match so nested routes like
// '/listings/watchlist' resolve to themselves instead of being collapsed
// to '/listings'.
const allKeys = [];
const collect = (nodes) => {
for (const n of nodes) {
if (typeof n.itemKey === 'string' && n.itemKey.startsWith('/')) allKeys.push(n.itemKey);
if (Array.isArray(n.items)) collect(n.items);
}
};
collect(items);
const longestMatch = allKeys
.filter((k) => name === k || name.startsWith(k + '/'))
.sort((a, b) => b.length - a.length)[0];
if (longestMatch) return longestMatch;
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}

View File

@@ -63,7 +63,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
</Tag>
)}
{job.isOnlyShared && (
<Tooltip content="Shared with you read only">
<Tooltip content="Shared with you - read only">
<span style={{ display: 'flex', alignItems: 'center' }}>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</span>

View File

@@ -16,13 +16,14 @@ import {
} from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js';
import StatusControl from '../listings/StatusControl.jsx';
import './ListingsTable.less';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
*/
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
<div className="listingsTable">
{listings.map((item) => (
<div
@@ -56,7 +57,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
{item.price}
</>
) : (
<span className="listingsTable__row__empty"></span>
<span className="listingsTable__row__empty">---</span>
)}
</div>
@@ -67,7 +68,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
{item.address}
</>
) : (
<span className="listingsTable__row__empty"></span>
<span className="listingsTable__row__empty">---</span>
)}
</div>
@@ -79,14 +80,22 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
<StatusControl
status={item.status?.status ?? null}
compact
onChange={(next) => onStatusChange?.(item, next)}
onTriggerClick={(e) => e.stopPropagation()}
/>
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
<Tooltip content="Original Listing">
<Button
size="small"