mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
storing the date when a status was set
This commit is contained in:
@@ -127,7 +127,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
|
|||||||
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
|
md += `| ID | Title | Address | Price | Size | Provider | Active | Status | Created | Job |\n`;
|
||||||
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
|
md += `|----|-------|---------|-------|------|----------|--------|--------|---------|-----|\n`;
|
||||||
for (const l of listings) {
|
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'} | ${cell(l.status)} | ${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`;
|
md += `\nUse **get_listing** with an ID for full details (description, link, image).\n`;
|
||||||
} else {
|
} else {
|
||||||
@@ -156,7 +156,10 @@ export function normalizeGetListing(listing) {
|
|||||||
md += `- **Link:** ${listing.link || '–'}\n`;
|
md += `- **Link:** ${listing.link || '–'}\n`;
|
||||||
md += `- **Image:** ${listing.image_url || '–'}\n`;
|
md += `- **Image:** ${listing.image_url || '–'}\n`;
|
||||||
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
|
md += `- **Active:** ${listing.is_active ? 'yes' : 'no'}\n`;
|
||||||
md += `- **Status:** ${listing.status || '–'}\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 += `- **Created:** ${formatDate(listing.created_at)}\n`;
|
||||||
md += `- **Job:** ${listing.job_name || '–'}\n`;
|
md += `- **Job:** ${listing.job_name || '–'}\n`;
|
||||||
if (listing.latitude != null && listing.longitude != null) {
|
if (listing.latitude != null && listing.longitude != null) {
|
||||||
|
|||||||
@@ -3,10 +3,27 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* 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 SqliteConnection from './SqliteConnection.js';
|
||||||
import { nanoid } from 'nanoid';
|
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.
|
* Return a list of known listing hashes for a given job and provider.
|
||||||
* Useful to de-duplicate before inserting new listings.
|
* Useful to de-duplicate before inserting new listings.
|
||||||
@@ -319,7 +336,9 @@ export const queryListings = ({
|
|||||||
} else if (watchListFilter === false) {
|
} else if (watchListFilter === false) {
|
||||||
whereParts.push('(wl.id IS NULL)');
|
whereParts.push('(wl.id IS NULL)');
|
||||||
}
|
}
|
||||||
// statusFilter: 'applied'|'rejected'|'accepted' -> equality; 'none' -> 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') {
|
if (statusFilter === 'none') {
|
||||||
whereParts.push('(l.status IS NULL)');
|
whereParts.push('(l.status IS NULL)');
|
||||||
} else if (
|
} else if (
|
||||||
@@ -327,7 +346,7 @@ export const queryListings = ({
|
|||||||
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
|
['applied', 'rejected', 'accepted'].includes(statusFilter.toLowerCase())
|
||||||
) {
|
) {
|
||||||
params.statusValue = statusFilter.toLowerCase();
|
params.statusValue = statusFilter.toLowerCase();
|
||||||
whereParts.push('(l.status = @statusValue)');
|
whereParts.push(`(json_extract(l.status, '$.status') = @statusValue)`);
|
||||||
}
|
}
|
||||||
// Time range filters (unix timestamps in milliseconds)
|
// Time range filters (unix timestamps in milliseconds)
|
||||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||||
@@ -403,7 +422,7 @@ export const queryListings = ({
|
|||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { totalNumber, page: safePage, result: rows };
|
return { totalNumber, page: safePage, result: rows.map(parseListingStatus) };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -638,7 +657,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
|
|||||||
if (!isAdmin) {
|
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))`;
|
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(
|
SqliteConnection.query(
|
||||||
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||||
FROM listings l
|
FROM listings l
|
||||||
@@ -646,7 +665,7 @@ export const getListingById = (id, userId = null, isAdmin = false) => {
|
|||||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
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}`,
|
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||||
params,
|
params,
|
||||||
)[0] || null
|
)[0] || null,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -674,6 +693,10 @@ export const setListingNotes = (id, notes) => {
|
|||||||
/**
|
/**
|
||||||
* Set or clear the status of a single listing.
|
* 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 {string} id - The listing ID.
|
||||||
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
|
* @param {('applied'|'rejected'|'accepted'|null)} status - New status, or null to clear.
|
||||||
* @returns {number} Number of rows affected (0 if listing not found).
|
* @returns {number} Number of rows affected (0 if listing not found).
|
||||||
@@ -685,9 +708,10 @@ export const setListingStatus = (id, status) => {
|
|||||||
if (normalized != null && !allowed.includes(normalized)) {
|
if (normalized != null && !allowed.includes(normalized)) {
|
||||||
throw new Error(`Invalid listing status: ${status}`);
|
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`, {
|
const res = SqliteConnection.execute(`UPDATE listings SET status = @status WHERE id = @id`, {
|
||||||
id,
|
id,
|
||||||
status: normalized,
|
status: payload,
|
||||||
});
|
});
|
||||||
return res?.changes ?? 0;
|
return res?.changes ?? 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
export function up(db) {
|
export function up(db) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
ALTER TABLE listings ADD COLUMN status TEXT;
|
ALTER TABLE listings ADD COLUMN status JSON;
|
||||||
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (status);
|
CREATE INDEX IF NOT EXISTS idx_listings_status ON listings (json_extract(status, '$.status'));
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,15 +42,21 @@ describe('listingsStorage.setListingStatus', () => {
|
|||||||
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
|
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs an UPDATE with the normalized status and listing id', () => {
|
it('runs an UPDATE storing a JSON payload with status and setAt', () => {
|
||||||
|
const before = Date.now();
|
||||||
const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
|
const changes = listingsStorage.setListingStatus('listing-1', 'Applied');
|
||||||
|
const after = Date.now();
|
||||||
expect(changes).toBe(1);
|
expect(changes).toBe(1);
|
||||||
expect(calls.execute).toHaveLength(1);
|
expect(calls.execute).toHaveLength(1);
|
||||||
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
|
expect(calls.execute[0].sql).toMatch(/UPDATE listings SET status = @status WHERE id = @id/);
|
||||||
expect(calls.execute[0].params).toEqual({ id: 'listing-1', status: 'applied' });
|
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', () => {
|
it('accepts null to clear the status (no JSON wrapping)', () => {
|
||||||
listingsStorage.setListingStatus('listing-2', null);
|
listingsStorage.setListingStatus('listing-2', null);
|
||||||
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
|
expect(calls.execute[0].params).toEqual({ id: 'listing-2', status: null });
|
||||||
});
|
});
|
||||||
@@ -87,10 +93,10 @@ describe('listingsStorage.queryListings statusFilter', () => {
|
|||||||
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
|
expect(pageQuery.sql).toMatch(/\(l\.status IS NULL\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds 'l.status = @statusValue' for a concrete status", () => {
|
it('extracts the inner status field via json_extract for a concrete status', () => {
|
||||||
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
|
listingsStorage.queryListings({ statusFilter: 'applied', userId: 'u1', isAdmin: true });
|
||||||
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
||||||
expect(pageQuery.sql).toMatch(/\(l\.status = @statusValue\)/);
|
expect(pageQuery.sql).toMatch(/json_extract\(l\.status, '\$\.status'\) = @statusValue/);
|
||||||
expect(pageQuery.params.statusValue).toBe('applied');
|
expect(pageQuery.params.statusValue).toBe('applied');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,6 +105,49 @@ describe('listingsStorage.queryListings statusFilter', () => {
|
|||||||
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
||||||
expect(pageQuery.sql).not.toMatch(/status/i);
|
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', () => {
|
describe('watchListStorage.ensureWatch', () => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
|
|
||||||
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<StatusControl
|
<StatusControl
|
||||||
status={item.status ?? null}
|
status={item.status?.status ?? null}
|
||||||
compact
|
compact
|
||||||
onChange={(next) => onStatusChange?.(item, next)}
|
onChange={(next) => onStatusChange?.(item, next)}
|
||||||
onTriggerClick={(e) => e.stopPropagation()}
|
onTriggerClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
|
|
||||||
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<StatusControl
|
<StatusControl
|
||||||
status={item.status ?? null}
|
status={item.status?.status ?? null}
|
||||||
compact
|
compact
|
||||||
onChange={(next) => onStatusChange?.(item, next)}
|
onChange={(next) => onStatusChange?.(item, next)}
|
||||||
onTriggerClick={(e) => e.stopPropagation()}
|
onTriggerClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Toast,
|
Toast,
|
||||||
TextArea,
|
TextArea,
|
||||||
|
Tooltip,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
@@ -315,35 +316,58 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
if (!listing) return null;
|
if (!listing) return null;
|
||||||
|
|
||||||
|
const statusLabel = listing.status?.status
|
||||||
|
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
|
||||||
|
: null;
|
||||||
|
|
||||||
const data = [
|
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',
|
key: 'Size',
|
||||||
value: listing.size ? `${listing.size} m²` : 'N/A',
|
value: listing.size ? `${listing.size} m²` : 'N/A',
|
||||||
Icon: <IconExpand />,
|
Icon: <IconExpand />,
|
||||||
|
helpText: 'Living space of the listing in square meters.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Rooms',
|
key: 'Rooms',
|
||||||
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
|
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
|
||||||
Icon: <IconGridView />,
|
Icon: <IconGridView />,
|
||||||
|
helpText: 'Number of rooms in the listing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Job',
|
key: 'Job',
|
||||||
value: listing.job_name,
|
value: listing.job_name,
|
||||||
Icon: <IconBriefcase />,
|
Icon: <IconBriefcase />,
|
||||||
|
helpText: 'The Fredy job that found this listing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Provider',
|
key: 'Provider',
|
||||||
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
|
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
|
||||||
Icon: <IconBriefcase />,
|
Icon: <IconBriefcase />,
|
||||||
|
helpText: 'The real estate portal where this listing was scraped from.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Added',
|
key: 'Added',
|
||||||
value: timeService.format(listing.created_at),
|
value: timeService.format(listing.created_at),
|
||||||
Icon: <IconClock />,
|
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 (
|
return (
|
||||||
<div className="listing-detail">
|
<div className="listing-detail">
|
||||||
<Headline
|
<Headline
|
||||||
@@ -381,7 +405,7 @@ export default function ListingDetail() {
|
|||||||
>
|
>
|
||||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||||
</Button>
|
</Button>
|
||||||
<StatusControl status={listing.status ?? null} onChange={handleStatusChange} />
|
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
|
||||||
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
|
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
|
||||||
<IconLink style={{ marginRight: 6 }} />
|
<IconLink style={{ marginRight: 6 }} />
|
||||||
Open listing
|
Open listing
|
||||||
@@ -450,10 +474,12 @@ export default function ListingDetail() {
|
|||||||
<Descriptions column={1}>
|
<Descriptions column={1}>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<Descriptions.Item key={index}>
|
<Descriptions.Item key={index}>
|
||||||
<Space>
|
<Tooltip content={item.helpText} position="left">
|
||||||
{item.Icon}
|
<span className="listing-detail__details-item">
|
||||||
{item.value}
|
{item.Icon}
|
||||||
</Space>
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
))}
|
))}
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|||||||
@@ -171,6 +171,13 @@
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__details-item {
|
||||||
|
cursor: help;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&__map-container {
|
&__map-container {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user