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

@@ -11,6 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title>
<link rel="icon" type="image/png" href="/ui/src/assets/heart.png" />
<link rel="apple-touch-icon" href="/ui/src/assets/heart.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />

View File

@@ -12,4 +12,6 @@ export const TRACKING_POIS = {
BASE_URL_SETTING: 'BASE_URL_SETTING',
SET_PROXY_SETTING: 'SET_PROXY_SETTING',
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
NOTES_CREATE: 'NOTES_CREATE',
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
};

View File

@@ -10,6 +10,8 @@ import logger from '../../services/logger.js';
import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js';
import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
/**
* @param {import('fastify').FastifyInstance} fastify
@@ -23,6 +25,7 @@ export default async function listingsPlugin(fastify) {
jobNameFilter,
providerFilter,
watchListFilter,
statusFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
@@ -35,6 +38,11 @@ export default async function listingsPlugin(fastify) {
};
const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter);
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
? statusFilter.toLowerCase()
: undefined;
let jobFilter = null;
let jobIdFilter = null;
@@ -54,6 +62,7 @@ export default async function listingsPlugin(fastify) {
jobIdFilter: jobIdFilter,
providerFilter,
watchListFilter: normalizedWatch,
statusFilter: normalizedStatus,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser,
@@ -94,6 +103,55 @@ export default async function listingsPlugin(fastify) {
return reply.send();
});
fastify.post('/:listingId/notes', async (request, reply) => {
const { listingId } = request.params || {};
const { notes } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
try {
const changes = listingStorage.setListingNotes(listingId, typeof notes === 'string' ? notes : null);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing notes' });
}
await trackPoi(TRACKING_POIS.NOTES_CREATE);
return reply.send();
});
fastify.post('/:listingId/status', async (request, reply) => {
const { listingId } = request.params || {};
const { status } = request.body || {};
const userId = request.session?.currentUser;
if (!listingId || !userId) {
return reply.code(400).send({ message: 'listingId or user not provided' });
}
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
return reply.code(400).send({ message: `Invalid status: ${status}` });
}
try {
const changes = listingStorage.setListingStatus(listingId, normalized);
await trackPoi(TRACKING_POIS.USING_LISTING_STATUS);
if (changes === 0) {
return reply.code(404).send({ message: 'Listing not found' });
}
if (normalized != null) {
watchListStorage.ensureWatch(listingId, userId);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ message: 'Failed to update listing status' });
}
return reply.send();
});
fastify.delete('/job', async (request, reply) => {
const { jobId, hardDelete = false } = request.body;
const settings = await getSettings();

View File

@@ -155,6 +155,12 @@ export function createMcpServer() {
),
sortField: z.string().optional().describe('Sort by: created_at, price, size, provider, title, is_active'),
sortDir: z.string().optional().describe('Sort direction: asc or desc'),
status: z
.enum(['applied', 'rejected', 'accepted', 'none'])
.optional()
.describe(
'Filter by user-set status. "applied", "rejected", or "accepted" return only listings with that status; "none" returns only listings without a status set.',
),
},
async (
{
@@ -170,6 +176,7 @@ export function createMcpServer() {
maxPrice,
sortField,
sortDir,
status,
},
extra,
) => {
@@ -192,6 +199,7 @@ export function createMcpServer() {
maxPrice: maxPrice ?? null,
sortField: sortField ?? null,
sortDir: sortDir ?? 'desc',
statusFilter: status,
userId: user.id,
isAdmin: user.isAdmin,
});

View File

@@ -124,10 +124,10 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
md += '\n\n';
if (listings.length > 0) {
md += `| ID | Title | Address | Price | Size | Provider | Active | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|---------|-----|\n`;
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
for (const l of listings) {
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
md += `| ${cell(l.id)} | ${cell(l.title)} | ${cell(l.address)} | ${cell(l.price)} | ${cell(l.size)} | ${cell(l.provider)} | ${l.is_active ? 'yes' : 'no'} | ${cell(l.status?.status)} | ${formatDate(l.created_at)} | ${cell(l.job_name)} |\n`;
}
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
} else {
@@ -156,6 +156,10 @@ export function normalizeGetListing(listing) {
md += `- **Link:** ${listing.link || ''}\n`;
md += `- **Image:** ${listing.image_url || ''}\n`;
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
md += `- **Status:** ${listing.status?.status || ''}\n`;
if (listing.status?.setAt) {
md += `- **Status set at:** ${formatDate(listing.status.setAt)}\n`;
}
md += `- **Created:** ${formatDate(listing.created_at)}\n`;
md += `- **Job:** ${listing.job_name || ''}\n`;
if (listing.latitude != null && listing.longitude != null) {

View File

@@ -3,10 +3,27 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { nullOrEmpty } from '../../utils.js';
import { nullOrEmpty, fromJson } from '../../utils.js';
import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid';
/**
* Parse the JSON `status` column of a listing row in place.
*
* The DB stores status as a JSON payload `{ status, setAt }` (or NULL).
* Consumers expect an object/null, so we normalize before returning.
*
* @param {Object|null|undefined} row - A raw row from the listings table.
* @returns {Object|null|undefined} The same row with `status` parsed.
*/
const parseListingStatus = (row) => {
if (row == null) return row;
if (typeof row.status === 'string') {
row.status = fromJson(row.status, null);
}
return row;
};
/**
* Return a list of known listing hashes for a given job and provider.
* Useful to de-duplicate before inserting new listings.
@@ -244,6 +261,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {object} [params.jobNameFilter]
* @param {object} [params.providerFilter]
* @param {object} [params.watchListFilter]
* @param {('applied'|'rejected'|'accepted'|'none')} [params.statusFilter] - Filter by listing status. 'none' matches NULL.
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
* @param {('asc'|'desc')} [params.sortDir='asc']
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
@@ -260,6 +278,7 @@ export const queryListings = ({
jobIdFilter,
providerFilter,
watchListFilter,
statusFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
@@ -317,6 +336,18 @@ export const queryListings = ({
} else if (watchListFilter === false) {
whereParts.push('(wl.id IS NULL)');
}
// statusFilter: 'applied'|'rejected'|'accepted' -> equality on JSON status field; 'none' -> NULL.
// The status column is a JSON payload `{ status, setAt }`, so we extract the inner
// status string for comparison instead of matching the raw text.
if (statusFilter === 'none') {
whereParts.push('(l.status IS NULL)');
} else if (
typeof statusFilter === 'string' &&
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
) {
params.statusValue = statusFilter.toLowerCase();
whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`);
}
// Time range filters (unix timestamps in milliseconds)
if (Number.isFinite(createdAfter) && createdAfter > 0) {
params.createdAfter = createdAfter;
@@ -391,7 +422,7 @@ export const queryListings = ({
params,
);
return { totalNumber, page: safePage, result: rows };
return { totalNumber, page: safePage, result: rows.map(parseListingStatus) };
};
/**
@@ -626,7 +657,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
}
return (
return parseListingStatus(
SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
@@ -634,10 +665,57 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params,
)[0] || null
)[0] || null,
);
};
/**
* Set or clear the notes attached to a single listing.
*
* Empty strings are normalized to NULL so the DB doesn't keep meaningless
* whitespace and queries can filter "has notes" with a simple IS NOT NULL.
*
* @param {string} id - The listing ID.
* @param {string|null} notes - The note text to store, or null/empty to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingNotes = (id, notes) => {
if (!id) return 0;
const trimmed = typeof notes === 'string' ? notes.trim() : null;
const value = trimmed && trimmed.length > 0 ? trimmed : null;
const res = SqliteConnection.execute(`UPDATE listings SET notes = @notes WHERE id = @id`, {
id,
notes: value,
});
return res?.changes ?? 0;
};
/**
* Set or clear the status of a single listing.
*
* The status column stores a JSON payload `{ status, setAt }` so consumers
* can show both the user's decision and when it was made. Passing `null`
* clears the column.
*
* @param {string} id - The listing ID.
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
* @returns {number} Number of rows affected (0 if listing not found).
*/
export const setListingStatus = (id, status) => {
if (!id) return 0;
const allowed = ['applied', 'rejected', 'accepted'];
const normalized = status == null ? null : String(status).toLowerCase();
if (normalized != null && !allowed.includes(normalized)) {
throw new Error(`Invalid listing status: ${status}`);
}
const payload = normalized == null ? null : JSON.stringify({ status: normalized, setAt: Date.now() });
const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, {
id,
status: payload,
});
return res?.changes ?? 0;
};
/**
* Resets geocoordinates and distance for all listings related to a user.
*

View File

@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN status JSON;
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status'));
`);
}

View File

@@ -0,0 +1,10 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN notes TEXT;
`);
}

View File

@@ -47,6 +47,25 @@ export const deleteWatch = (listingId, userId) => {
return { deleted: Boolean(res?.changes) };
};
/**
* Ensure a watch entry exists. Does not toggle; safe to call when row may already exist.
* Used by the status endpoint to auto-watch a listing when a status is set.
* @param {string} listingId
* @param {string} userId
* @returns {{watched:boolean}}
*/
export const ensureWatch = (listingId, userId) => {
if (!listingId || !userId) return { watched: false };
const { created } = createWatch(listingId, userId);
if (created) return { watched: true };
const exists =
SqliteConnection.query(
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
{ listing_id: listingId, user_id: userId },
).length > 0;
return { watched: exists };
};
/**
* Toggle a watch entry. If exists -> delete, otherwise create.
* @param {string} listingId

View File

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

View File

@@ -0,0 +1,193 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// We mock SqliteConnection so we can assert which SQL the storage layer
// runs and with which params, without spinning up a real SQLite DB.
const calls = {
execute: [],
query: [],
};
const sqliteMock = {
execute: (sql, params) => {
calls.execute.push({ sql, params });
// Default: pretend 1 row was affected (so setListingStatus reports success).
return { changes: 1 };
},
query: (sql, params) => {
calls.query.push({ sql, params });
// Return shape varies by test — overridden via queryHandler when needed.
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
return [];
},
__queryHandler: null,
};
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
default: sqliteMock,
}));
describe('listingsStorage.setListingStatus', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('runs an UPDATE storing a JSON payload with status and setAt', () => {
const before = Date.now();
const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
const after = Date.now();
expect(changes).toBe(1);
expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
expect(calls.execute[0].params.id).toBe('listing-1');
const parsed = JSON.parse(calls.execute[0].params.status);
expect(parsed.status).toBe('applied');
expect(parsed.setAt).toBeGreaterThanOrEqual(before);
expect(parsed.setAt).toBeLessThanOrEqual(after);
});
it('accepts null to clear the status (no JSON wrapping)', () => {
listingsStorage.setListingStatus('listing-2', null);
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
});
it('rejects invalid statuses', () => {
expect(() => listingsStorage.setListingStatus('listing-3', 'maybe')).toThrow(/Invalid listing status/);
expect(calls.execute).toHaveLength(0);
});
it('returns 0 when no id is supplied (no SQL is run)', () => {
const result = listingsStorage.setListingStatus(null, 'applied');
expect(result).toBe(0);
expect(calls.execute).toHaveLength(0);
});
});
describe('listingsStorage.queryListings statusFilter', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
// Return empty rows for both the count and the page-fetch queries.
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
return [];
};
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it("adds 'l.status IS NULL' to WHERE when statusFilter is 'none'", () => {
listingsStorage.queryListings({ statusFilter: 'none', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
});
it('extracts the inner status field via json_extract for a concrete status', () => {
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/);
expect(pageQuery.params.statusValue).toBe('applied');
});
it('ignores unknown statusFilter values silently', () => {
listingsStorage.queryListings({ statusFilter: 'bogus', userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).not.toMatch(/status/i);
});
it('parses the JSON status payload of returned rows into an object', () => {
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 2 }];
return [
{ id: 'a', status: JSON.stringify({ status: 'applied', setAt: 1700000000000 }) },
{ id: 'b', status: null },
];
};
const result = listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
expect(result.result[0].status).toEqual({ status: 'applied', setAt: 1700000000000 });
expect(result.result[1].status).toBeNull();
});
});
describe('listingsStorage.getListingById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('parses the JSON status payload of the returned row', () => {
sqliteMock.__queryHandler = () => [
{ id: 'a', status: JSON.stringify({ status: 'rejected', setAt: 1700000000001 }) },
];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toEqual({ status: 'rejected', setAt: 1700000000001 });
});
it('returns null status untouched', () => {
sqliteMock.__queryHandler = () => [{ id: 'a', status: null }];
const row = listingsStorage.getListingById('a', 'u1', true);
expect(row.status).toBeNull();
});
it('returns null when no row is found', () => {
sqliteMock.__queryHandler = () => [];
const row = listingsStorage.getListingById('missing', 'u1', true);
expect(row).toBeNull();
});
});
describe('watchListStorage.ensureWatch', () => {
let watchListStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
watchListStorage = await import('../../lib/services/storage/watchListStorage.js');
});
it('inserts and reports watched=true on first call', () => {
// After INSERT, createWatch queries for existence and gets a row back.
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-1', 'user-1');
expect(result).toEqual({ watched: true });
// INSERT should have been issued.
expect(calls.execute.some((c) => /INSERT INTO watch_list/.test(c.sql))).toBe(true);
});
it('returns watched=true when an entry already exists', () => {
// Simulate ON CONFLICT being a no-op: execute reports no changes, then SELECT confirms row exists.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 0 };
};
sqliteMock.__queryHandler = () => [{ ok: 1 }];
const result = watchListStorage.ensureWatch('listing-2', 'user-2');
expect(result).toEqual({ watched: true });
// Restore execute to default for subsequent tests.
sqliteMock.execute = (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 1 };
};
});
it('returns watched=false when listingId or userId is missing', () => {
expect(watchListStorage.ensureWatch(null, 'u')).toEqual({ watched: false });
expect(watchListStorage.ensureWatch('l', null)).toEqual({ watched: false });
expect(calls.execute).toHaveLength(0);
});
});

View File

@@ -97,6 +97,7 @@ export default function FredyApp() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Route path="/listings/watchlist" element={<Listings mode="watchlist" />} />
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
<Route path="/map" element={<MapView />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />

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,6 +50,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
<span>Inactive</span>
</div>
)}
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
<button
type="button"
className="listingsGrid__card__star"
@@ -57,6 +59,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
>
{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,6 +181,7 @@ const ListingsOverview = () => {
<Radio value="false">Inactive</Radio>
</RadioGroup>
{!isWatchlistMode && (
<RadioGroup
type="button"
buttonSize="middle"
@@ -162,6 +196,23 @@ const ListingsOverview = () => {
<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"
@@ -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,6 +80,13 @@ 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()}>
<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"
@@ -87,6 +95,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</Tooltip>
<Tooltip content="Original Listing">
<Button
size="small"

View File

@@ -260,6 +260,22 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings/map. Error:', Exception);
}
},
async setListingStatus(listingId, status) {
try {
await xhrPost(`/api/listings/${listingId}/status`, { status });
} catch (Exception) {
console.error(`Error while trying to set status for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
async setListingNotes(listingId, notes) {
try {
await xhrPost(`/api/listings/${listingId}/notes`, { notes });
} catch (Exception) {
console.error(`Error while trying to set notes for listing ${listingId}. Error:`, Exception);
throw Exception;
}
},
},
userSettings: {
async getUserSettings() {

View File

@@ -20,6 +20,8 @@ import {
Banner,
Spin,
Toast,
TextArea,
Tooltip,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
@@ -44,6 +46,7 @@ import { xhrPost, xhrDelete } from '../../services/xhr.js';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Headline from '../../components/headline/Headline.jsx';
import StatusControl from '../../components/listings/StatusControl.jsx';
import './ListingDetail.less';
const { Title, Text } = Typography;
@@ -65,6 +68,8 @@ export default function ListingDetail() {
const map = useRef(null);
const [loading, setLoading] = useState(true);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [notesDraft, setNotesDraft] = useState('');
const [notesSaving, setNotesSaving] = useState(false);
useEffect(() => {
async function fetchListing() {
@@ -82,6 +87,10 @@ export default function ListingDetail() {
fetchListing();
}, [listingId]);
useEffect(() => {
setNotesDraft(listing?.notes ?? '');
}, [listing?.id, listing?.notes]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
@@ -271,6 +280,32 @@ export default function ListingDetail() {
}
};
const handleStatusChange = async (next) => {
try {
await actions.listingsData.setListingStatus(listing.id, next);
await actions.listingsData.getListing(listingId);
Toast.success(next ? `Marked as ${next}` : 'Status cleared');
} catch (e) {
console.error('Failed to update status:', e);
Toast.error('Failed to update status');
}
};
const handleSaveNotes = async () => {
if (!listing) return;
setNotesSaving(true);
try {
await actions.listingsData.setListingNotes(listing.id, notesDraft);
await actions.listingsData.getListing(listingId);
Toast.success('Notes saved');
} catch (e) {
console.error('Failed to save notes:', e);
Toast.error('Failed to save notes');
} finally {
setNotesSaving(false);
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
@@ -281,35 +316,58 @@ export default function ListingDetail() {
if (!listing) return null;
const statusLabel = listing.status?.status
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
: null;
const data = [
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Price',
value: `${listing.price}`,
Icon: <IconCart />,
helpText: 'The asking price of this listing, as reported by the provider.',
},
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconExpand />,
helpText: 'Living space of the listing in square meters.',
},
{
key: 'Rooms',
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
Icon: <IconGridView />,
helpText: 'Number of rooms in the listing.',
},
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
helpText: 'The Fredy job that found this listing.',
},
{
key: 'Provider',
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />,
helpText: 'The real estate portal where this listing was scraped from.',
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
helpText: 'When Fredy first added this listing to your database.',
},
];
if (statusLabel) {
data.push({
key: 'Status',
value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel,
Icon: <IconActivity />,
helpText: 'The status you marked for this listing and when you set it.',
});
}
return (
<div className="listing-detail">
<Headline
@@ -347,6 +405,7 @@ export default function ListingDetail() {
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} />
Open listing
@@ -380,6 +439,32 @@ export default function ListingDetail() {
preview={!!listing.image_url}
/>
</div>
<div className="listing-detail__notes">
<Title heading={4} className="listing-detail__notes-title">
Notes
</Title>
<TextArea
value={notesDraft}
onChange={(val) => setNotesDraft(val)}
placeholder="Your private notes about this listing…"
rows={5}
autosize={{ minRows: 4, maxRows: 12 }}
className="listing-detail__notes-textarea"
showClear
/>
<Space className="listing-detail__notes-actions">
<Button
theme="solid"
type="primary"
loading={notesSaving}
disabled={notesSaving || (notesDraft ?? '') === (listing.notes ?? '')}
onClick={handleSaveNotes}
>
Store notes
</Button>
</Space>
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
@@ -389,10 +474,12 @@ export default function ListingDetail() {
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
<Tooltip content={item.helpText} position="left">
<span className="listing-detail__details-item">
{item.Icon}
{item.value}
</Space>
</span>
</Tooltip>
</Descriptions.Item>
))}
</Descriptions>

View File

@@ -89,6 +89,49 @@
}
}
&__notes {
padding: 1.5rem;
border-top: 1px solid var(--semi-color-border);
}
&__notes-title {
margin-bottom: 1rem !important;
}
&__notes-actions {
margin-top: 0.75rem;
width: 100%;
justify-content: flex-end;
}
&__notes-textarea {
background: #2a2a2a !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
box-shadow: none !important;
outline: none !important;
transition: border-color @transition-fast, background @transition-fast !important;
textarea {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
outline: none !important;
color: @color-text !important;
font-family: @font-ui !important;
font-size: @text-base !important;
}
&.semi-input-textarea-focus,
&:focus,
&:focus-within {
border-color: @color-accent !important;
box-shadow: none !important;
outline: none !important;
background: #2f2f2f !important;
}
}
&__watch-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
@@ -128,6 +171,13 @@
padding: 1.5rem;
}
&__details-item {
cursor: help;
display: inline-flex;
align-items: center;
gap: 8px;
}
&__map-container {
height: 400px;
width: 100%;

View File

@@ -6,11 +6,15 @@
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
import Headline from '../../components/headline/Headline.jsx';
export default function Listings() {
/**
* @param {{ mode?: 'all' | 'watchlist' }} props
*/
export default function Listings({ mode = 'all' }) {
const title = mode === 'watchlist' ? 'Watchlist' : 'Listings';
return (
<>
<Headline text="Listings" />
<ListingsOverview />
<Headline text={title} />
<ListingsOverview mode={mode} />
</>
);
}