diff --git a/index.html b/index.html index 9ca38ea..044c13c 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,8 @@ Fredy || Real Estate Finder + + diff --git a/lib/TRACKING_POIS.js b/lib/TRACKING_POIS.js index 63bc884..f8dbba7 100644 --- a/lib/TRACKING_POIS.js +++ b/lib/TRACKING_POIS.js @@ -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', }; diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 7ab3c58..cd956a8 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -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(); diff --git a/lib/mcp/mcpAdapter.js b/lib/mcp/mcpAdapter.js index 52f72b1..4b4acc0 100644 --- a/lib/mcp/mcpAdapter.js +++ b/lib/mcp/mcpAdapter.js @@ -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, }); diff --git a/lib/mcp/mcpNormalizer.js b/lib/mcp/mcpNormalizer.js index 12cf8a5..e22c949 100644 --- a/lib/mcp/mcpNormalizer.js +++ b/lib/mcp/mcpNormalizer.js @@ -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) { diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index fa01b0a..86a2801 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -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. * diff --git a/lib/services/storage/migrations/sql/18.add-listing-status.js b/lib/services/storage/migrations/sql/18.add-listing-status.js new file mode 100644 index 0000000..9552cdc --- /dev/null +++ b/lib/services/storage/migrations/sql/18.add-listing-status.js @@ -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')); + `); +} diff --git a/lib/services/storage/migrations/sql/19.add-listing-notes.js b/lib/services/storage/migrations/sql/19.add-listing-notes.js new file mode 100644 index 0000000..7925705 --- /dev/null +++ b/lib/services/storage/migrations/sql/19.add-listing-notes.js @@ -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; + `); +} diff --git a/lib/services/storage/watchListStorage.js b/lib/services/storage/watchListStorage.js index 3e9910e..626ad91 100644 --- a/lib/services/storage/watchListStorage.js +++ b/lib/services/storage/watchListStorage.js @@ -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 diff --git a/package.json b/package.json index b0f8c60..293f94d 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/storage/listingStatus.test.js b/test/storage/listingStatus.test.js new file mode 100644 index 0000000..6e21fcf --- /dev/null +++ b/test/storage/listingStatus.test.js @@ -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); + }); +}); diff --git a/ui/src/App.jsx b/ui/src/App.jsx index cff9c61..69d776f 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -97,6 +97,7 @@ export default function FredyApp() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index e76e962..b931df1 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -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 }) => (
{listings.map((item) => (
( Inactive
)} - + + +
@@ -83,6 +86,12 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
e.stopPropagation()}> + onStatusChange?.(item, next)} + onTriggerClick={(e) => e.stopPropagation()} + /> + + ); + + return ( + setOpen(false)} + position="bottom" + render={menu} + stopPropagation + > + {trigger} + + ); +} diff --git a/ui/src/components/listings/StatusControl.less b/ui/src/components/listings/StatusControl.less new file mode 100644 index 0000000..39ee85d --- /dev/null +++ b/ui/src/components/listings/StatusControl.less @@ -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; + } + } +} diff --git a/ui/src/components/navigation/Navigation.jsx b/ui/src/components/navigation/Navigation.jsx index 5f9685d..095f1a9 100644 --- a/ui/src/components/navigation/Navigation.jsx +++ b/ui/src/components/navigation/Navigation.jsx @@ -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]; } diff --git a/ui/src/components/table/JobsTable.jsx b/ui/src/components/table/JobsTable.jsx index 627796d..fdd7734 100644 --- a/ui/src/components/table/JobsTable.jsx +++ b/ui/src/components/table/JobsTable.jsx @@ -63,7 +63,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob )} {job.isOnlyShared && ( - + diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx index 60faaf6..54c06c1 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/ListingsTable.jsx @@ -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 }) => (
{listings.map((item) => (
( {item.price} ) : ( - + --- )}
@@ -67,7 +68,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => ( {item.address} ) : ( - + --- )}
@@ -79,14 +80,22 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
{timeService.format(item.created_at, false)}
e.stopPropagation()}> - + onStatusChange?.(item, next)} + onTriggerClick={(e) => e.stopPropagation()} + /> + + + + Open listing @@ -380,6 +439,32 @@ export default function ListingDetail() { preview={!!listing.image_url} />
+ +
+ + Notes + +