Compare commits

..

1 Commits

Author SHA1 Message Date
orangecoding
703c602527 table overview for jobs 2026-05-07 15:59:55 +02:00
9 changed files with 438 additions and 127 deletions

View File

@@ -126,4 +126,21 @@ export default async function userSettingsPlugin(fastify) {
return reply.code(500).send({ error: error.message }); return reply.code(500).send({ error: error.message });
} }
}); });
fastify.post('/jobs-view-mode', async (request, reply) => {
const userId = request.session.currentUser;
const { jobs_view_mode } = request.body;
if (jobs_view_mode !== 'grid' && jobs_view_mode !== 'table') {
return reply.code(400).send({ error: 'jobs_view_mode must be "grid" or "table".' });
}
try {
upsertSettings({ jobs_view_mode }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating jobs view mode setting', error);
return reply.code(500).send({ error: error.message });
}
});
} }

View File

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

BIN
ui/src/assets/news/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

View File

@@ -1,11 +1,16 @@
{ {
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876515", "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876221",
"content": "content":
[ [
{ {
"title": "Table overview for listings", "title": "Table overview for listings",
"text": "Thanks to https://github.com/datenwurm, we now have a table overview for listings. If you decide to use the table view, the decision will be stored.", "text": "Thanks to https://github.com/datenwurm, we now have a table overview for listings. If you decide to use the table view, the decision will be stored.",
"media": "1.png" "media": "1.png"
},
{
"title": "Table overview for jobs",
"text": "Based on datenwurm's, work, I created a table overview for jobs. If you decide to use the table view, the decision will be stored.",
"media": "2.png"
} }
] ]
} }

View File

@@ -21,6 +21,7 @@ import {
Empty, Empty,
Radio, Radio,
RadioGroup, RadioGroup,
Tooltip,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -35,6 +36,8 @@ import {
IconArrowUp, IconArrowUp,
IconArrowDown, IconArrowDown,
IconHome, IconHome,
IconGridView,
IconList,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
@@ -42,6 +45,7 @@ import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js'; import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import { debounce } from '../../../utils'; import { debounce } from '../../../utils';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import JobsTable from '../../table/JobsTable.jsx';
import './JobGrid.less'; import './JobGrid.less';
@@ -54,6 +58,9 @@ const JobGrid = () => {
const actions = useActions(); const actions = useActions();
const navigate = useNavigate(); const navigate = useNavigate();
const userSettings = useSelector((state) => state.userSettings.settings);
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 12; const pageSize = 12;
@@ -234,6 +241,27 @@ const JobGrid = () => {
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))} onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'} title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/> />
<div className="jobGrid__topbar__view-toggle">
<Tooltip content="Grid view">
<Button
icon={<IconGridView />}
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setJobsViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
/>
</Tooltip>
<Tooltip content="Table view">
<Button
icon={<IconList />}
theme={viewMode === 'table' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setJobsViewMode('table')}
aria-label="Table view"
aria-pressed={viewMode === 'table'}
/>
</Tooltip>
</div>
</div> </div>
{(jobsData?.result || []).length === 0 && ( {(jobsData?.result || []).length === 0 && (
@@ -244,6 +272,7 @@ const JobGrid = () => {
/> />
)} )}
{viewMode === 'grid' ? (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => ( {(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}> <Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
@@ -257,11 +286,7 @@ const JobGrid = () => {
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
{job.isOnlyShared && ( {job.isOnlyShared && (
<Popover <Popover content={getPopoverContent('This job has been shared with you — read only.')}>
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div> <div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} /> <IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</div> </div>
@@ -283,13 +308,13 @@ const JobGrid = () => {
</span> </span>
</div> </div>
<div className="jobGrid__card__stat jobGrid__card__stat--orange"> <div className="jobGrid__card__stat jobGrid__card__stat--orange">
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span> <span className="jobGrid__card__stat__number">{job.provider?.length || 0}</span>
<span className="jobGrid__card__stat__label"> <span className="jobGrid__card__stat__label">
<IconBriefcase size="small" /> Providers <IconBriefcase size="small" /> Providers
</span> </span>
</div> </div>
<div className="jobGrid__card__stat jobGrid__card__stat--purple"> <div className="jobGrid__card__stat jobGrid__card__stat--purple">
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span> <span className="jobGrid__card__stat__number">{job.notificationAdapter?.length || 0}</span>
<span className="jobGrid__card__stat__label"> <span className="jobGrid__card__stat__label">
<IconBell size="small" /> Adapters <IconBell size="small" /> Adapters
</span> </span>
@@ -374,6 +399,17 @@ const JobGrid = () => {
</Col> </Col>
))} ))}
</Row> </Row>
) : (
<JobsTable
jobs={jobsData?.result || []}
onRun={onJobRun}
onEdit={(id) => navigate(`/jobs/edit/${id}`)}
onClone={(id) => navigate('/jobs/new', { state: { cloneFrom: id } })}
onDeleteListings={onListingRemoval}
onDeleteJob={onJobRemoval}
onStatusChange={onJobStatusChanged}
/>
)}
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && ( {(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
<div className="jobGrid__pagination"> <div className="jobGrid__pagination">
<Pagination <Pagination

View File

@@ -17,6 +17,12 @@
flex: 1; flex: 1;
min-width: 160px; min-width: 160px;
} }
&__view-toggle {
display: flex;
gap: 4px;
flex-shrink: 0;
}
} }
&__card { &__card {

View File

@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Button, Tag, Tooltip, Switch } from '@douyinfe/semi-ui-19';
import {
IconAlertTriangle,
IconBell,
IconBriefcase,
IconCopy,
IconDelete,
IconDescend2,
IconEdit,
IconHome,
IconPlayCircle,
} from '@douyinfe/semi-icons';
import './JobsTable.less';
/**
* @param {{ jobs: object[], onRun: Function, onEdit: Function, onClone: Function, onDeleteListings: Function, onDeleteJob: Function, onStatusChange: Function }} props
*/
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => (
<div className="jobsTable">
{jobs.map((job) => (
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
<div className="jobsTable__row__dot">
<span
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
/>
</div>
<div className="jobsTable__row__name" title={job.name}>
{job.name}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
<IconHome size="small" />
{job.numberOfFoundListings || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
<IconBriefcase size="small" />
{job.provider?.length || 0}
</div>
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
<IconBell size="small" />
{job.notificationAdapter?.length || 0}
</div>
<div className="jobsTable__row__badges">
<Switch
size="small"
checked={job.enabled}
disabled={job.isOnlyShared}
onChange={(checked) => onStatusChange(job.id, checked)}
/>
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
</Tag>
)}
{job.isOnlyShared && (
<Tooltip content="Shared with you — read only">
<span style={{ display: 'flex', alignItems: 'center' }}>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</span>
</Tooltip>
)}
</div>
<div className="jobsTable__row__actions">
<Tooltip content="Run Job">
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onRun(job.id)}
/>
</Tooltip>
<Tooltip content="Edit Job">
<Button
type="secondary"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => onEdit(job.id)}
/>
</Tooltip>
<Tooltip content="Clone Job">
<Button
type="tertiary"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => onClone(job.id)}
/>
</Tooltip>
<Tooltip content="Delete all found Listings">
<Button
type="danger"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onDeleteListings(job.id)}
/>
</Tooltip>
<Tooltip content="Delete Job">
<Button
type="danger"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onDeleteJob(job.id)}
/>
</Tooltip>
</div>
</div>
))}
</div>
);
export default JobsTable;

View File

@@ -0,0 +1,105 @@
@import '../../tokens.less';
.jobsTable {
display: flex;
flex-direction: column;
gap: 4px;
&__row {
display: grid;
grid-template-columns: 24px 1fr 80px 80px 80px auto auto;
align-items: center;
gap: @space-3;
padding: 8px 12px;
background: @color-elevated;
border: 1px solid @color-border;
border-radius: @radius-chip;
transition: background @transition-fast;
&:hover {
background: #252525;
}
&--inactive {
opacity: 0.6;
}
&__dot {
display: flex;
align-items: center;
justify-content: center;
&__indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: rgba(251, 113, 133, 0.7);
&--active {
background-color: rgba(52, 211, 153, 0.8);
}
}
}
&__name {
font-weight: 600;
font-size: @text-sm;
color: @color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__stat {
font-size: @text-sm;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
&--blue {
color: @color-blue-text;
}
&--orange {
color: @color-orange-text;
}
&--purple {
color: @color-purple-text;
}
}
&__badges {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
&__actions {
display: flex;
align-items: center;
gap: 2px;
}
@media (max-width: 900px) {
grid-template-columns: 24px 1fr 80px auto auto;
.jobsTable__row__stat--orange,
.jobsTable__row__stat--purple {
display: none;
}
}
@media (max-width: 560px) {
grid-template-columns: 24px 1fr auto auto;
.jobsTable__row__stat--blue {
display: none;
}
}
}
}

View File

@@ -335,6 +335,20 @@ export const useFredyState = create(
throw Exception; throw Exception;
} }
}, },
async setJobsViewMode(jobs_view_mode) {
try {
await xhrPost('/api/user/settings/jobs-view-mode', { jobs_view_mode });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, jobs_view_mode },
},
}));
} catch (Exception) {
console.error('Error while trying to update jobs view mode setting. Error:', Exception);
throw Exception;
}
},
}, },
}; };