mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
committed by
GitHub
parent
5ceac25aa6
commit
44edf47393
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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%;
|
||||
|
||||
115
ui/src/components/listings/StatusControl.jsx
Normal file
115
ui/src/components/listings/StatusControl.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
ui/src/components/listings/StatusControl.less
Normal file
64
ui/src/components/listings/StatusControl.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user