1st approach for projects, items, db structure

This commit is contained in:
Jurgis Sakalauskas
2025-10-13 14:04:22 +03:00
parent ad2cf25bfd
commit afc3da3e65
45 changed files with 4743 additions and 117 deletions
-10
View File
@@ -1,10 +0,0 @@
import { apiFetch } from './http-client';
export type HealthResponse = {
status: string;
service: string;
};
export function getHealth() {
return apiFetch<HealthResponse>('/health');
}
+23 -2
View File
@@ -1,7 +1,7 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
type ApiFetchOptions = Omit<RequestInit, 'body'> & {
body?: Record<string, unknown>;
body?: Record<string, unknown> | null;
};
export async function apiFetch<TResponse>(path: string, options: ApiFetchOptions = {}): Promise<TResponse> {
@@ -18,7 +18,28 @@ export async function apiFetch<TResponse>(path: string, options: ApiFetchOptions
const response = await fetch(`${API_BASE_URL}${path}`, init);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
let message = `Request failed: ${response.status}`;
try {
const errorPayload = await response.clone().json();
const extractedMessage =
(errorPayload && errorPayload.error && errorPayload.error.message) ??
errorPayload.message;
if (typeof extractedMessage === 'string' && extractedMessage.trim().length > 0) {
message = extractedMessage;
}
} catch {
// ignore JSON parsing issues - fall back to status text when available
if (response.statusText) {
message = `${message} ${response.statusText}`;
}
}
throw new Error(message);
}
if (response.status === 204) {
return null as TResponse;
}
return response.json() as Promise<TResponse>;
+108
View File
@@ -0,0 +1,108 @@
import { apiFetch } from './http-client';
export type PriceCondition = 'new' | 'used';
export type PriceSourceType = 'url' | 'manual';
export interface ItemPrice {
id: string;
itemId: string;
condition: PriceCondition;
amount: number;
currency: string;
sourceType: PriceSourceType;
sourceUrlId: string | null;
sourceUrl: string | null;
sourceNote: string | null;
note: string | null;
observedAt: string;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
}
interface ListResponseMeta {
limit: number;
offset: number;
count: number;
}
export interface ItemPricesResponse {
data: ItemPrice[];
meta: ListResponseMeta;
}
export interface FetchItemPricesParams {
limit?: number;
offset?: number;
condition?: PriceCondition;
signal?: AbortSignal;
}
export const fetchItemPrices = async (
itemId: string,
params: FetchItemPricesParams = {}
): Promise<ItemPricesResponse> => {
const searchParams = new URLSearchParams();
if (params.limit !== undefined) {
searchParams.set('limit', String(params.limit));
}
if (params.offset !== undefined) {
searchParams.set('offset', String(params.offset));
}
if (params.condition) {
searchParams.set('condition', params.condition);
}
const query = searchParams.toString();
const path = query
? `/items/${itemId}/prices?${query}`
: `/items/${itemId}/prices`;
return apiFetch<ItemPricesResponse>(path, { signal: params.signal });
};
export interface CreateItemPricePayload {
condition: PriceCondition;
amount: number;
currency: string;
sourceType: PriceSourceType;
sourceUrl?: string | null;
sourceUrlId?: string | null;
sourceNote?: string | null;
note?: string | null;
observedAt?: string;
isPrimary?: boolean;
}
interface SingleItemPriceResponse {
data: ItemPrice;
}
export const createItemPrice = async (
itemId: string,
payload: CreateItemPricePayload
): Promise<ItemPrice> => {
const response = await apiFetch<SingleItemPriceResponse>(
`/items/${itemId}/prices`,
{
method: 'POST',
body: {
condition: payload.condition,
amount: payload.amount,
currency: payload.currency,
sourceType: payload.sourceType,
sourceUrl: payload.sourceUrl ?? null,
sourceUrlId: payload.sourceUrlId ?? null,
sourceNote: payload.sourceNote ?? null,
note: payload.note ?? null,
observedAt: payload.observedAt ?? null,
isPrimary: payload.isPrimary ?? false
}
}
);
return response.data;
};
+107
View File
@@ -0,0 +1,107 @@
import { apiFetch } from './http-client';
export type ItemStatus = 'active' | 'rejected';
export interface Item {
id: string;
projectId: string;
manufacturer: string | null;
model: string;
sourceUrlId: string | null;
sourceUrl: string | null;
status: ItemStatus;
note: string | null;
attributes: Record<string, unknown>;
createdAt: string;
updatedAt: string;
priceSummary: ItemPriceSummary | null;
}
export interface ItemPriceSummary {
minAmount: number;
maxAmount: number;
currency: string | null;
priceCount: number;
hasMixedCurrency: boolean;
}
interface ListResponseMeta {
limit: number;
offset: number;
count: number;
}
export interface ProjectItemsResponse {
data: Item[];
meta: ListResponseMeta;
}
export interface FetchProjectItemsParams {
limit?: number;
offset?: number;
status?: ItemStatus;
signal?: AbortSignal;
}
export const fetchProjectItems = async (
projectId: string,
params: FetchProjectItemsParams = {}
): Promise<ProjectItemsResponse> => {
const searchParams = new URLSearchParams();
if (params.limit !== undefined) {
searchParams.set('limit', String(params.limit));
}
if (params.offset !== undefined) {
searchParams.set('offset', String(params.offset));
}
if (params.status) {
searchParams.set('status', params.status);
}
const query = searchParams.toString();
const path = query
? `/projects/${projectId}/items?${query}`
: `/projects/${projectId}/items`;
return apiFetch<ProjectItemsResponse>(path, { signal: params.signal });
};
export interface CreateItemPayload {
manufacturer?: string | null;
model: string;
status?: ItemStatus;
note?: string | null;
attributes?: Record<string, unknown>;
sourceUrl?: string | null;
sourceUrlId?: string | null;
}
interface SingleItemResponse {
data: Item;
}
export const createItem = async (
projectId: string,
payload: CreateItemPayload
): Promise<Item> => {
const response = await apiFetch<SingleItemResponse>(
`/projects/${projectId}/items`,
{
method: 'POST',
body: {
manufacturer: payload.manufacturer ?? null,
model: payload.model,
status: payload.status ?? 'active',
note: payload.note ?? null,
attributes: payload.attributes ?? {},
sourceUrl: payload.sourceUrl ?? null,
sourceUrlId: payload.sourceUrlId ?? null
}
}
);
return response.data;
};
+120
View File
@@ -0,0 +1,120 @@
import { apiFetch } from './http-client';
export type ProjectStatus = 'active' | 'archived';
export interface Project {
id: string;
name: string;
description: string | null;
status: ProjectStatus;
attributes: string[];
priorityRules: unknown;
createdAt: string;
updatedAt: string;
}
interface ListResponseMeta {
limit: number;
offset: number;
count: number;
}
export interface ProjectsListResponse {
data: Project[];
meta: ListResponseMeta;
}
export interface FetchProjectsParams {
limit?: number;
offset?: number;
status?: ProjectStatus;
signal?: AbortSignal;
}
export const fetchProjects = async (
params: FetchProjectsParams = {}
): Promise<ProjectsListResponse> => {
const searchParams = new URLSearchParams();
if (params.limit !== undefined) {
searchParams.set('limit', String(params.limit));
}
if (params.offset !== undefined) {
searchParams.set('offset', String(params.offset));
}
if (params.status) {
searchParams.set('status', params.status);
}
const query = searchParams.toString();
const path = query ? `/projects?${query}` : '/projects';
return apiFetch<ProjectsListResponse>(path, { signal: params.signal });
};
export interface CreateProjectPayload {
name: string;
description?: string | null;
status?: ProjectStatus;
attributes?: string[];
priorityRules?: unknown;
}
interface SingleProjectResponse {
data: Project;
}
export interface UpdateProjectPayload {
name?: string;
description?: string | null;
status?: ProjectStatus;
attributes?: string[];
priorityRules?: unknown;
}
export const createProject = async (
payload: CreateProjectPayload
): Promise<Project> => {
const response = await apiFetch<SingleProjectResponse>('/projects', {
method: 'POST',
body: {
name: payload.name,
description: payload.description ?? null,
status: payload.status ?? undefined,
attributes: payload.attributes ?? [],
priorityRules: payload.priorityRules ?? []
}
});
return response.data;
};
export interface FetchProjectParams {
signal?: AbortSignal;
}
export const fetchProject = async (
projectId: string,
params: FetchProjectParams = {}
): Promise<Project> => {
const response = await apiFetch<SingleProjectResponse>(
`/projects/${projectId}`,
{ signal: params.signal }
);
return response.data;
};
export const updateProject = async (
projectId: string,
payload: UpdateProjectPayload
): Promise<Project> => {
const response = await apiFetch<SingleProjectResponse>(`/projects/${projectId}`, {
method: 'PATCH',
body: payload
});
return response.data;
};
+285
View File
@@ -0,0 +1,285 @@
import { FormEvent, useEffect, useState } from 'react';
import type { CreateItemPayload, ItemStatus } from '../api/items';
const itemStatusOptions: ItemStatus[] = ['active', 'rejected'];
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
export interface ItemFormModalProps {
isOpen: boolean;
mode: 'url' | 'manual';
initialUrl: string | null;
onClose: () => void;
onSuccess: () => void;
onSubmit: (payload: CreateItemPayload) => Promise<unknown>;
isSubmitting: boolean;
projectAttributes: string[];
}
export function ItemFormModal({
isOpen,
mode,
initialUrl,
onClose,
onSuccess,
onSubmit,
isSubmitting,
projectAttributes
}: ItemFormModalProps) {
const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState('');
const [status, setStatus] = useState<ItemStatus>('active');
const [note, setNote] = useState('');
const [sourceUrl, setSourceUrl] = useState('');
const [attributesJson, setAttributesJson] = useState('{}');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
return;
}
setManufacturer('');
setModel('');
setStatus('active');
setNote('');
setAttributesJson('{}');
setError(null);
setSourceUrl(initialUrl ?? '');
}, [isOpen, initialUrl, mode]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) {
return null;
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (!model.trim()) {
setError('Model is required.');
return;
}
let attributes: Record<string, unknown> = {};
const trimmedAttributes = attributesJson.trim();
if (trimmedAttributes) {
try {
const parsed = JSON.parse(trimmedAttributes);
if (!isPlainObject(parsed)) {
setError('Attributes must be a JSON object.');
return;
}
attributes = parsed;
} catch (parseError) {
setError(`Attributes JSON is invalid: ${(parseError as Error).message}`);
return;
}
}
try {
await onSubmit({
manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
model: model.trim(),
status,
note: note.trim() ? note.trim() : null,
attributes,
sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null
});
onSuccess();
} catch (submitError) {
setError((submitError as Error).message);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4"
role="dialog"
aria-modal="true"
onClick={() => {
if (!isSubmitting) {
onClose();
}
}}
>
<div
className="relative w-full max-w-3xl rounded-2xl bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500 transition hover:bg-slate-200"
disabled={isSubmitting}
>
Close
</button>
<div className="px-8 py-6">
<header className="mb-6">
<h2 className="text-xl font-semibold text-slate-900">
{mode === 'url' ? 'Review Item Details' : 'Add Item Manually'}
</h2>
<p className="text-sm text-slate-500">
{mode === 'url'
? 'Double-check the imported information and fill in any gaps.'
: 'Provide the specs and notes for this item.'}
</p>
</header>
<form className="grid grid-cols-1 gap-5 md:grid-cols-2" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-manufacturer">
Manufacturer / Brand
</label>
<input
id="modal-item-manufacturer"
type="text"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Logitech"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-model">
Model *
</label>
<input
id="modal-item-model"
type="text"
value={model}
onChange={(event) => setModel(event.target.value)}
required
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="MX Master 3"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-status">
Status
</label>
<select
id="modal-item-status"
value={status}
onChange={(event) => setStatus(event.target.value as ItemStatus)}
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
{itemStatusOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-source-url">
Source URL {mode === 'manual' ? '(optional)' : ''}
</label>
<input
id="modal-item-source-url"
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="https://example.com/specs"
/>
</div>
<div className="md:col-span-1 md:row-span-2 flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-note">
Note
</label>
<textarea
id="modal-item-note"
value={note}
onChange={(event) => setNote(event.target.value)}
rows={4}
className="h-full min-h-[96px] rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Why is this item interesting?"
/>
</div>
<div className="md:col-span-1 md:row-span-2 flex flex-col gap-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-attributes">
Attributes JSON
</label>
<textarea
id="modal-item-attributes"
value={attributesJson}
onChange={(event) => setAttributesJson(event.target.value)}
rows={6}
className="h-full min-h-[120px] font-mono rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder='{"dpi": 1000, "wireless": true}'
/>
</div>
<div className="md:col-span-2 flex flex-col gap-2">
<span className="text-sm font-medium text-slate-700">Tracked Attributes</span>
{projectAttributes.length ? (
<div className="flex flex-wrap gap-2">
{projectAttributes.map((attribute) => (
<span
key={attribute}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600"
>
{attribute}
</span>
))}
</div>
) : (
<p className="text-sm text-slate-400">No attributes configured for this project.</p>
)}
</div>
<div className="md:col-span-2 flex items-center justify-between">
<div className="text-xs text-slate-500">
Fields marked with * are required. Attributes must be valid JSON.
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving…' : 'Save Item'}
</button>
</div>
</div>
</form>
{error ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from 'react';
export interface TrackedAttributesModalProps {
isOpen: boolean;
availableAttributes: string[];
initialSelection: string[];
onClose: () => void;
onSubmit: (attributes: string[]) => Promise<void>;
isSubmitting: boolean;
errorMessage: string | null;
}
export function TrackedAttributesModal({
isOpen,
availableAttributes,
initialSelection,
onClose,
onSubmit,
isSubmitting,
errorMessage
}: TrackedAttributesModalProps) {
const [selectedAttributes, setSelectedAttributes] = useState<string[]>(initialSelection);
useEffect(() => {
if (!isOpen) {
return;
}
setSelectedAttributes(initialSelection);
}, [isOpen, initialSelection]);
const sortedAttributes = useMemo(
() => [...availableAttributes].sort((a, b) => a.localeCompare(b)),
[availableAttributes]
);
if (!isOpen) {
return null;
}
const toggleAttribute = (attribute: string) => {
setSelectedAttributes((current) =>
current.includes(attribute)
? current.filter((value) => value !== attribute)
: [...current, attribute]
);
};
const handleSubmit = async () => {
const normalized = Array.from(
new Set(
selectedAttributes
.map((attribute) => attribute.trim())
.filter((attribute) => attribute.length > 0)
)
);
await onSubmit(normalized);
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4"
role="dialog"
aria-modal="true"
onClick={() => {
if (!isSubmitting) {
onClose();
}
}}
>
<div
className="relative w-full max-w-2xl rounded-2xl bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500 transition hover:bg-slate-200"
disabled={isSubmitting}
>
Close
</button>
<div className="px-8 py-6">
<header className="mb-5 flex flex-col gap-1">
<h2 className="text-xl font-semibold text-slate-900">Tracked Attributes</h2>
<p className="text-sm text-slate-500">
Choose which attributes should be highlighted across the project. Changes may
influence upcoming priority rules.
</p>
</header>
{sortedAttributes.length ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="grid gap-3 sm:grid-cols-2">
{sortedAttributes.map((attribute) => {
const checkboxId = `tracked-attribute-${attribute.replace(/\s+/g, '-').toLowerCase()}`;
return (
<label
key={attribute}
htmlFor={checkboxId}
className="flex items-center gap-2 rounded-md border border-transparent px-2 py-1 text-sm text-slate-700 transition hover:border-slate-200"
>
<input
id={checkboxId}
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={selectedAttributes.includes(attribute)}
onChange={() => toggleAttribute(attribute)}
disabled={isSubmitting}
/>
<span>{attribute}</span>
</label>
);
})}
</div>
</div>
) : (
<p className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500">
No attributes available yet. Add item specs to select them here.
</p>
)}
{errorMessage ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
) : null}
<footer className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</footer>
</div>
</div>
</div>
);
}
+200 -38
View File
@@ -1,53 +1,215 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useProjectsQuery } from '../query/projects';
const navItems = [
{ label: 'Health Check', to: '/' },
// future nav items can be added here
const navItems: Array<{ label: string; to: string; end?: boolean }> = [
{ label: 'Projects', to: '/projects' }
];
function AppLayout() {
const location = useLocation();
const { data, isLoading, isError } = useProjectsQuery();
const projects = data?.data ?? [];
const recentProjects = useMemo(
() =>
[...projects]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
.slice(0, 3),
[projects]
);
const isInitialDesktop = typeof window !== 'undefined'
? window.matchMedia('(min-width: 768px)').matches
: false;
const [isDesktop, setIsDesktop] = useState(isInitialDesktop);
const [isNavOpen, setIsNavOpen] = useState(isInitialDesktop);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const media = window.matchMedia('(min-width: 768px)');
const handleChange = (event: MediaQueryListEvent) => {
setIsDesktop(event.matches);
setIsNavOpen(event.matches);
};
setIsDesktop(media.matches);
setIsNavOpen(media.matches);
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);
useEffect(() => {
if (!isDesktop) {
setIsNavOpen(false);
}
}, [location.pathname, isDesktop]);
const handleToggleNav = () => {
setIsNavOpen((current) => !current);
};
const handleNavLinkClick = () => {
if (!isDesktop) {
setIsNavOpen(false);
}
};
const asideClassName = isDesktop
? [
'relative z-30 flex flex-shrink-0 flex-col border-r border-slate-200 bg-white shadow-sm transition-all duration-200 ease-out',
isNavOpen ? 'w-64' : 'w-0 border-transparent shadow-none overflow-hidden'
].join(' ')
: [
'fixed inset-y-0 left-0 z-30 flex w-64 transform flex-col border-r border-slate-200 bg-white shadow-sm transition-transform duration-200 ease-out',
isNavOpen ? 'translate-x-0' : '-translate-x-full'
].join(' ');
const asideInnerClassName = [
'flex flex-1 flex-col',
isDesktop
? isNavOpen
? 'px-6 py-8'
: 'px-0 py-0 opacity-0 pointer-events-none'
: 'px-6 py-8'
].join(' ');
return (
<div className="flex min-h-screen bg-slate-50 text-slate-800">
<aside className="flex w-64 flex-col border-r border-slate-200 bg-white px-6 py-8 shadow-sm">
<div className="mb-8">
<span className="block text-sm font-semibold uppercase tracking-wide text-slate-400">
BestChoice
</span>
<h1 className="text-2xl font-semibold text-slate-900">Control Center</h1>
{!isDesktop && isNavOpen ? (
<button
type="button"
aria-label="Hide navigation"
className="fixed inset-0 z-20 bg-slate-900/30 md:hidden"
onClick={() => setIsNavOpen(false)}
/>
) : null}
<aside
id="app-sidebar"
className={asideClassName}
aria-hidden={!isNavOpen}
>
<div className={asideInnerClassName}>
<div className="mb-8">
<span className="block text-sm font-semibold uppercase tracking-wide text-slate-400">
BestChoice
</span>
<h1 className="text-2xl font-semibold text-slate-900">Control Center</h1>
</div>
<nav className="flex flex-1 flex-col">
<div className="flex flex-col gap-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={handleNavLinkClick}
className={({ isActive }) =>
[
'rounded-md px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
].join(' ')
}
>
{item.label}
</NavLink>
))}
</div>
<div className="mt-8">
<p className="px-3 text-xs font-semibold uppercase tracking-wide text-slate-400">
Recent Projects
</p>
<div className="mt-3 flex flex-col gap-1">
{isLoading ? (
<span className="px-3 py-2 text-xs text-slate-400">
Loading projects
</span>
) : isError ? (
<span className="px-3 py-2 text-xs text-red-500">
Couldn&apos;t load projects.
</span>
) : recentProjects.length ? (
recentProjects.map((project) => (
<NavLink
key={project.id}
to={`/projects/${project.id}`}
onClick={handleNavLinkClick}
className={({ isActive }) =>
[
'rounded-md px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-blue-50 text-blue-700 shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
].join(' ')
}
>
<span className="block truncate">{project.name}</span>
</NavLink>
))
) : (
<span className="px-3 py-2 text-xs text-slate-400">
No projects yet.
</span>
)}
</div>
</div>
</nav>
<footer className="mt-8 text-xs text-slate-400">
<p>&copy; {new Date().getFullYear()} BestChoice</p>
</footer>
</div>
<nav className="flex flex-1 flex-col gap-2">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end
className={({ isActive }) =>
[
'rounded-md px-3 py-2 text-sm font-medium transition',
isActive
? 'bg-blue-600 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
].join(' ')
}
>
{item.label}
</NavLink>
))}
</nav>
<footer className="mt-8 text-xs text-slate-400">
<p>&copy; {new Date().getFullYear()} BestChoice</p>
</footer>
</aside>
<div className="flex flex-1 flex-col">
<header className="border-b border-slate-200 bg-white px-8 py-6 shadow-sm">
<h2 className="text-xl font-semibold text-slate-900">Dashboard</h2>
<p className="text-sm text-slate-500">Monitor service health and status</p>
<header className="border-b border-slate-200 bg-white px-4 py-4 shadow-sm md:px-8 md:py-6">
<div className="flex items-center gap-3">
<button
type="button"
aria-label={isNavOpen ? 'Hide navigation' : 'Show navigation'}
aria-controls="app-sidebar"
aria-expanded={isNavOpen}
onClick={handleToggleNav}
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-200 text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 md:h-9 md:w-9"
>
{isNavOpen ? (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M14.78 5.22a.75.75 0 010 1.06L11.06 10l3.72 3.72a.75.75 0 11-1.06 1.06L10 11.06l-3.72 3.72a.75.75 0 11-1.06-1.06L8.94 10 5.22 6.28a.75.75 0 011.06-1.06L10 8.94l3.72-3.72a.75.75 0 011.06 0z"
clipRule="evenodd"
/>
</svg>
) : (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 5.75A.75.75 0 013.75 5h12.5a.75.75 0 010 1.5H3.75A.75.75 0 013 5.75zm0 4.25A.75.75 0 013.75 9h12.5a.75.75 0 010 1.5H3.75A.75.75 0 013 9.75zm0 4.25a.75.75 0 01.75-.75h12.5a.75.75 0 010 1.5H3.75a.75.75 0 01-.75-.75z" />
</svg>
)}
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">Projects Dashboard</h2>
<p className="text-sm text-slate-500">
Organize research projects, compare items, and monitor price trends.
</p>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto px-8 py-10">
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
<Outlet />
</main>
</div>
-39
View File
@@ -1,39 +0,0 @@
import { useHealthQuery } from '../query/use-health-query';
function HealthPage() {
const appName = import.meta.env.VITE_APP_NAME ?? 'BestChoice';
const { data, isLoading, isError, error, refetch, isFetching } = useHealthQuery();
const statusText = (() => {
if (isLoading) {
return 'loading...';
}
if (isError) {
return 'unreachable';
}
return data?.status ?? 'unknown';
})();
return (
<section className="mx-auto flex max-w-md flex-col items-center gap-4 px-4 py-12 text-center text-slate-800">
<h1 className="text-4xl font-semibold">{appName}</h1>
<p className="text-lg">
API status: <span className="font-medium text-blue-600">{statusText}</span>
</p>
<p className="text-sm text-slate-500">Service: {data?.service ?? 'n/a'}</p>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="rounded-full bg-blue-600 px-5 py-2 text-white transition enabled:hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-400"
>
{isFetching ? 'Refreshing…' : 'Refresh'}
</button>
{isError ? <p className="text-sm text-red-600">{(error as Error).message}</p> : null}
</section>
);
}
export default HealthPage;
@@ -0,0 +1,315 @@
import { FormEvent, useMemo, useState } from 'react';
import { useCreateItemPriceMutation, useItemPricesQuery } from '../../query/item-prices';
import type { PriceCondition, PriceSourceType } from '../../api/item-prices';
const priceConditionOptions: PriceCondition[] = ['new', 'used'];
const priceSourceOptions: PriceSourceType[] = ['url', 'manual'];
interface ItemPricesPanelProps {
itemId: string;
}
export function ItemPricesPanel({ itemId }: ItemPricesPanelProps) {
const [condition, setCondition] = useState<PriceCondition>('new');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const [sourceType, setSourceType] = useState<PriceSourceType>('manual');
const [sourceUrl, setSourceUrl] = useState('');
const [note, setNote] = useState('');
const [sourceNote, setSourceNote] = useState('');
const [observedAt, setObservedAt] = useState('');
const [isPrimary, setIsPrimary] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const pricesQuery = useItemPricesQuery(itemId, true);
const createPriceMutation = useCreateItemPriceMutation(itemId);
const prices = pricesQuery.data?.data ?? [];
const creationError = useMemo(
() => formError ?? (createPriceMutation.isError ? createPriceMutation.error.message : null),
[formError, createPriceMutation.isError, createPriceMutation.error]
);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setFormError(null);
const parsedAmount = Number(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
setFormError('Amount must be a non-negative number.');
return;
}
try {
await createPriceMutation.mutateAsync({
condition,
amount: parsedAmount,
currency: currency.trim().toUpperCase(),
sourceType,
sourceUrl: sourceType === 'url' ? (sourceUrl.trim() || null) : null,
note: note.trim() ? note.trim() : null,
sourceNote: sourceNote.trim() ? sourceNote.trim() : null,
observedAt: observedAt ? new Date(observedAt).toISOString() : undefined,
isPrimary
});
setAmount('');
setSourceUrl('');
setNote('');
setSourceNote('');
setObservedAt('');
setIsPrimary(true);
} catch (error) {
setFormError((error as Error).message);
}
};
return (
<div className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
<header className="flex flex-col gap-2 border-b border-slate-100 pb-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">Prices</h3>
<p className="text-sm text-slate-500">
Track new / used pricing to compare deals across sources.
</p>
</div>
<span className="text-sm text-slate-500">
{pricesQuery.isLoading
? 'Loading prices…'
: prices.length
? `${prices.length} price point${prices.length === 1 ? '' : 's'}`
: 'No prices recorded yet'}
</span>
</header>
{pricesQuery.isError ? (
<p className="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">
{(pricesQuery.error as Error).message}
</p>
) : null}
<div className="mt-4 grid gap-4 md:grid-cols-2">
<form
className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-slate-50 p-4"
onSubmit={handleSubmit}
>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-condition-${itemId}`}>
Condition
</label>
<select
id={`price-condition-${itemId}`}
value={condition}
onChange={(event) => setCondition(event.target.value as PriceCondition)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
{priceConditionOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-amount-${itemId}`}>
Amount
</label>
<input
id={`price-amount-${itemId}`}
type="number"
min={0}
step="0.01"
value={amount}
onChange={(event) => setAmount(event.target.value)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="129.99"
required
/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-currency-${itemId}`}>
Currency
</label>
<input
id={`price-currency-${itemId}`}
type="text"
value={currency}
onChange={(event) => setCurrency(event.target.value.toUpperCase())}
maxLength={3}
className="uppercase rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="USD"
required
/>
</div>
<div className="flex items-center gap-2 pt-5">
<input
id={`price-primary-${itemId}`}
type="checkbox"
checked={isPrimary}
onChange={(event) => setIsPrimary(event.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label className="text-xs font-medium text-slate-600" htmlFor={`price-primary-${itemId}`}>
Mark as current best price
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-source-type-${itemId}`}>
Source Type
</label>
<select
id={`price-source-type-${itemId}`}
value={sourceType}
onChange={(event) => setSourceType(event.target.value as PriceSourceType)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
>
{priceSourceOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-observed-${itemId}`}>
Observed At
</label>
<input
id={`price-observed-${itemId}`}
type="datetime-local"
value={observedAt}
onChange={(event) => setObservedAt(event.target.value)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
/>
</div>
</div>
{sourceType === 'url' ? (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-source-url-${itemId}`}>
Source URL
</label>
<input
id={`price-source-url-${itemId}`}
type="url"
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="https://shop.example.com/deal"
required
/>
</div>
) : null}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-source-note-${itemId}`}>
Source Note
</label>
<input
id={`price-source-note-${itemId}`}
type="text"
value={sourceNote}
onChange={(event) => setSourceNote(event.target.value)}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Amazon seller"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-600" htmlFor={`price-note-${itemId}`}>
Notes
</label>
<textarea
id={`price-note-${itemId}`}
value={note}
onChange={(event) => setNote(event.target.value)}
rows={2}
className="rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Includes extra grip kit"
/>
</div>
{creationError ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-600">{creationError}</p>
) : null}
<div className="flex items-center justify-end gap-3 pt-1">
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
disabled={createPriceMutation.isPending}
>
{createPriceMutation.isPending ? 'Adding…' : 'Add Price'}
</button>
</div>
</form>
<div className="space-y-3">
{prices.map((price) => (
<article
key={price.id}
className="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm"
>
<header className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{price.condition} {price.amount.toFixed(2)} {price.currency}
</span>
{price.isPrimary ? (
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-blue-600">
Primary
</span>
) : null}
</header>
<dl className="mt-2 space-y-1 text-xs text-slate-600">
<div>
<dt className="font-medium text-slate-500">Source</dt>
<dd className="capitalize">{price.sourceType}</dd>
</div>
{price.sourceUrl ? (
<div>
<dt className="font-medium text-slate-500">URL</dt>
<dd>
<a
href={price.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-700"
>
View source
</a>
</dd>
</div>
) : null}
{price.sourceNote ? (
<div>
<dt className="font-medium text-slate-500">Source Note</dt>
<dd>{price.sourceNote}</dd>
</div>
) : null}
{price.note ? (
<div>
<dt className="font-medium text-slate-500">Notes</dt>
<dd>{price.note}</dd>
</div>
) : null}
<div>
<dt className="font-medium text-slate-500">Observed</dt>
<dd>{new Date(price.observedAt).toLocaleString()}</dd>
</div>
</dl>
</article>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,105 @@
import { FormEvent, useEffect, useState } from 'react';
interface ProjectDescriptionSectionProps {
description: string | null;
isUpdating: boolean;
onUpdate: (nextDescription: string | null) => Promise<void>;
}
export function ProjectDescriptionSection({
description,
isUpdating,
onUpdate
}: ProjectDescriptionSectionProps) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(description ?? '');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isEditing) {
setDraft(description ?? '');
}
}, [description, isEditing]);
const startEditing = () => {
setError(null);
setDraft(description ?? '');
setIsEditing(true);
};
const cancelEditing = () => {
setError(null);
setDraft(description ?? '');
setIsEditing(false);
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const trimmed = draft.trim();
const normalizedDescription = trimmed.length ? trimmed : null;
try {
await onUpdate(normalizedDescription);
setIsEditing(false);
} catch (submitError) {
setError((submitError as Error).message);
}
};
return (
<div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">Description</h2>
{!isEditing ? (
<button
type="button"
onClick={startEditing}
className="text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:text-blue-700"
disabled={isUpdating}
>
Edit
</button>
) : null}
</div>
{isEditing ? (
<form className="mt-3 flex flex-col gap-3" onSubmit={handleSubmit}>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={5}
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="Add context about this project..."
disabled={isUpdating}
/>
<div className="flex items-center gap-3">
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
disabled={isUpdating}
>
{isUpdating ? 'Saving…' : 'Save Description'}
</button>
<button
type="button"
onClick={cancelEditing}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed"
disabled={isUpdating}
>
Cancel
</button>
</div>
{error ? (
<p className="text-sm font-medium text-red-600">{error}</p>
) : null}
</form>
) : (
<p className="mt-2 text-sm text-slate-600">
{description ?? 'No description provided yet.'}
</p>
)}
</div>
);
}
@@ -0,0 +1,171 @@
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useCreateItemMutation,
useProjectItemsQuery,
useProjectQuery,
useUpdateProjectMutation
} from '../../query/projects';
import { ItemFormModal } from '../../components/item-form-modal';
import { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section';
import { TrackedAttributesSection } from './tracked-attributes-section';
import { ProjectItemsSection } from './project-items-section';
export function ProjectDetailPage() {
const { projectId } = useParams<{ projectId: string }>();
const projectQuery = useProjectQuery(projectId);
const itemsQuery = useProjectItemsQuery(projectId);
const createItemMutation = useCreateItemMutation(projectId);
const updateProjectMutation = useUpdateProjectMutation(projectId);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [quickUrl, setQuickUrl] = useState('');
const [quickError, setQuickError] = useState<string | null>(null);
const [isItemModalOpen, setIsItemModalOpen] = useState(false);
const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual');
const [modalInitialUrl, setModalInitialUrl] = useState<string | null>(null);
const project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
const projectAttributes = useMemo(() => project?.attributes ?? [], [project]);
const availableAttributes = useMemo(() => {
const attributeSet = new Set<string>();
projectAttributes.forEach((attribute) => {
if (attribute.trim().length) {
attributeSet.add(attribute.trim());
}
});
items.forEach((item) => {
const itemAttributes = item.attributes ?? {};
Object.keys(itemAttributes).forEach((key) => {
const trimmed = key.trim();
if (trimmed.length) {
attributeSet.add(trimmed);
}
});
});
return Array.from(attributeSet);
}, [projectAttributes, items]);
const openItemModal = (mode: 'url' | 'manual', initialUrl: string | null) => {
setItemModalMode(mode);
setModalInitialUrl(initialUrl);
setIsItemModalOpen(true);
};
const handleAddUrlClick = () => {
const trimmed = quickUrl.trim();
if (!trimmed) {
setQuickError('Enter a URL to import item details.');
return;
}
setQuickError(null);
openItemModal('url', trimmed);
};
const handleAddManualClick = () => {
setQuickError(null);
openItemModal('manual', quickUrl.trim() || null);
};
const handleModalClose = () => {
setIsItemModalOpen(false);
setQuickError(null);
};
const handleModalSuccess = () => {
if (itemModalMode === 'url') {
setQuickUrl('');
}
setQuickError(null);
setIsItemModalOpen(false);
};
const handleDescriptionUpdate = async (description: string | null) => {
await updateProjectMutation.mutateAsync({ description });
};
const handleAttributesUpdate = async (attributes: string[]) => {
await updateProjectMutation.mutateAsync({ attributes });
};
if (!projectId) {
return <p className="text-sm text-red-600">Project identifier is missing from the URL.</p>;
}
if (projectQuery.isLoading) {
return <p className="text-sm text-slate-600">Loading project details</p>;
}
if (projectQuery.isError || !project) {
return (
<p className="text-sm text-red-600">
Failed to load project. {(projectQuery.error as Error | undefined)?.message}
</p>
);
}
const handleQuickUrlChange = (value: string) => {
setQuickUrl(value);
if (quickError) {
setQuickError(null);
}
};
const toggleItemExpansion = (itemId: string) => {
setExpandedItemId((current) => (current === itemId ? null : itemId));
};
return (
<div className="flex flex-col gap-10">
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<ProjectHeader name={project.name} status={project.status} />
<div className="mt-6 grid gap-6 md:grid-cols-2">
<ProjectDescriptionSection
description={project.description}
isUpdating={updateProjectMutation.isPending}
onUpdate={handleDescriptionUpdate}
/>
<TrackedAttributesSection
attributes={projectAttributes}
availableAttributes={availableAttributes}
isUpdating={updateProjectMutation.isPending}
onUpdate={handleAttributesUpdate}
/>
</div>
</section>
<ProjectItemsSection
items={items}
isLoading={itemsQuery.isLoading}
error={itemsQuery.isError ? (itemsQuery.error as Error) : null}
projectAttributes={projectAttributes}
expandedItemId={expandedItemId}
onToggleItem={toggleItemExpansion}
quickUrl={quickUrl}
quickError={quickError}
onQuickUrlChange={handleQuickUrlChange}
onAddUrl={handleAddUrlClick}
onAddManual={handleAddManualClick}
/>
<ItemFormModal
isOpen={isItemModalOpen}
mode={itemModalMode}
initialUrl={modalInitialUrl}
onClose={handleModalClose}
onSuccess={handleModalSuccess}
onSubmit={createItemMutation.mutateAsync}
isSubmitting={createItemMutation.isPending}
projectAttributes={projectAttributes}
/>
</div>
);
}
@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
import type { ProjectStatus } from '../../api/projects';
interface ProjectHeaderProps {
name: string;
status: ProjectStatus;
}
export function ProjectHeader({ name, status }: ProjectHeaderProps) {
return (
<header className="flex flex-col gap-2 border-b border-slate-100 pb-6 md:flex-row md:items-center md:justify-between">
<div>
<Link to="/projects" className="text-xs font-medium uppercase tracking-wide text-blue-600">
&larr; Back to projects
</Link>
<h1 className="text-2xl font-semibold text-slate-900">{name}</h1>
</div>
<div className="rounded-full bg-blue-50 px-4 py-1 text-sm font-medium text-blue-700 capitalize">
{status}
</div>
</header>
);
}
@@ -0,0 +1,519 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import type { Item } from '../../api/items';
import { ItemPricesPanel } from './item-prices-panel';
interface ProjectItemsSectionProps {
items: Item[];
isLoading: boolean;
error: Error | null;
projectAttributes: string[];
expandedItemId: string | null;
onToggleItem: (itemId: string) => void;
quickUrl: string;
quickError: string | null;
onQuickUrlChange: (value: string) => void;
onAddUrl: () => void;
onAddManual: () => void;
}
const formatAttributeValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '—';
}
const formattedValues = value
.map((item) => {
if (item === null || item === undefined) {
return '';
}
if (typeof item === 'string') {
return item.trim();
}
if (typeof item === 'number') {
return String(item);
}
if (typeof item === 'boolean') {
return item ? 'Yes' : 'No';
}
return JSON.stringify(item);
})
.filter((item) => item.length > 0);
return formattedValues.length ? formattedValues.join(', ') : '—';
}
if (typeof value === 'string') {
return value.length ? value : '—';
}
if (typeof value === 'number') {
return String(value);
}
return JSON.stringify(value);
};
const createSortKey = (value: unknown): string => {
if (Array.isArray(value)) {
return `array:${value.length}:${value.map((item) => createSortKey(item)).join('|')}`;
}
if (value === null) {
return 'null';
}
const type = typeof value;
if (type === 'number') {
return `${type}:${(value as number).toString()}`;
}
if (type === 'boolean') {
return `${type}:${value ? '1' : '0'}`;
}
if (type === 'string') {
return `${type}:${value}`;
}
return `${type}:${JSON.stringify(value)}`;
};
const normalizeAttributeValue = (value: unknown): unknown => {
if (value === null || value === undefined) {
return null;
}
if (Array.isArray(value)) {
const normalizedItems = value.map((item) => normalizeAttributeValue(item));
normalizedItems.sort((first, second) => {
const firstKey = createSortKey(first);
const secondKey = createSortKey(second);
if (firstKey < secondKey) {
return -1;
}
if (firstKey > secondKey) {
return 1;
}
return 0;
});
return normalizedItems;
}
return value;
};
const areAttributeValuesEqual = (first: unknown, second: unknown): boolean => {
const normalizedFirst = normalizeAttributeValue(first);
const normalizedSecond = normalizeAttributeValue(second);
if (Array.isArray(normalizedFirst) && Array.isArray(normalizedSecond)) {
if (normalizedFirst.length !== normalizedSecond.length) {
return false;
}
for (let index = 0; index < normalizedFirst.length; index += 1) {
if (!areAttributeValuesEqual(normalizedFirst[index], normalizedSecond[index])) {
return false;
}
}
return true;
}
return Object.is(normalizedFirst, normalizedSecond);
};
const formatPriceAmount = (amount: number, currency: string | null): string => {
if (!Number.isFinite(amount)) {
return '—';
}
if (currency) {
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 2
}).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
return amount.toFixed(2);
};
const getPriceSummaryDisplay = (
summary: Item['priceSummary'] | null
): { primary: string; secondary: string | null } => {
if (!summary || summary.priceCount === 0) {
return { primary: '—', secondary: null };
}
if (summary.hasMixedCurrency) {
const countLabel = `${summary.priceCount} price${summary.priceCount === 1 ? '' : 's'}`;
return {
primary: countLabel,
secondary: 'Multiple currencies'
};
}
const formattedMin = formatPriceAmount(summary.minAmount, summary.currency);
if (summary.priceCount === 1 || summary.minAmount === summary.maxAmount) {
return {
primary: formattedMin,
secondary: summary.priceCount > 1 ? `${summary.priceCount} prices` : null
};
}
const formattedMax = formatPriceAmount(summary.maxAmount, summary.currency);
return {
primary: `${formattedMin} ${formattedMax}`,
secondary: `${summary.priceCount} prices`
};
};
export function ProjectItemsSection({
items,
isLoading,
error,
projectAttributes,
expandedItemId,
onToggleItem,
quickUrl,
quickError,
onQuickUrlChange,
onAddUrl,
onAddManual
}: ProjectItemsSectionProps) {
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
const mismatchedAttributes = useMemo(() => {
const result = new Set<string>();
if (items.length <= 1 || projectAttributes.length === 0) {
return result;
}
projectAttributes.forEach((attribute) => {
const trimmed = attribute.trim();
if (!trimmed.length) {
return;
}
let referenceValue: unknown = null;
let hasReference = false;
for (const item of items) {
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const currentValue = attributes[attribute];
if (!hasReference) {
referenceValue = currentValue;
hasReference = true;
continue;
}
if (!areAttributeValuesEqual(currentValue, referenceValue)) {
result.add(attribute);
break;
}
}
});
return result;
}, [items, projectAttributes]);
useEffect(() => {
if (showDifferencesOnly && mismatchedAttributes.size === 0) {
setShowDifferencesOnly(false);
}
}, [showDifferencesOnly, mismatchedAttributes]);
const visibleAttributes = useMemo(() => {
if (!showDifferencesOnly) {
return projectAttributes;
}
return projectAttributes.filter((attribute) => mismatchedAttributes.has(attribute));
}, [showDifferencesOnly, projectAttributes, mismatchedAttributes]);
const totalColumns = baseColumnCount + visibleAttributes.length;
const hasAttributeDifferences = mismatchedAttributes.size > 0;
const showNoDifferencesMessage =
!hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1;
return (
<>
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Items</h2>
<p className="text-sm text-slate-500">
{isLoading
? 'Loading items…'
: items.length
? `${items.length} item${items.length === 1 ? '' : 's'} tracked.`
: 'No items yet add your first one below.'}
</p>
</div>
<label className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-slate-500">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-300"
checked={showDifferencesOnly}
onChange={(event) => setShowDifferencesOnly(event.target.checked)}
disabled={!hasAttributeDifferences}
/>
Show differences only
</label>
</header>
{error ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{error.message}</p>
) : null}
{showNoDifferencesMessage ? (
<p className="mb-4 rounded-md bg-slate-50 px-3 py-2 text-xs text-slate-600">
All tracked attributes match across items.
</p>
) : null}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Item
</th>
{visibleAttributes.map((attribute) => (
<th
key={attribute}
className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500"
>
{attribute}
</th>
))}
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Prices
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => {
const isExpanded = expandedItemId === item.id;
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
return (
<Fragment key={item.id}>
<tr className="hover:bg-slate-50">
<td className="px-4 py-3 align-top">
<div className="flex flex-col gap-1">
<span className="inline-flex items-center gap-2 text-slate-900">
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="font-semibold">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</span>
</span>
{item.note ? (
<span className="text-xs text-slate-500">{item.note}</span>
) : null}
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
View source
</a>
) : null}
</div>
</td>
{visibleAttributes.map((attribute) => (
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
{formatAttributeValue(attributes[attribute])}
</td>
))}
<td className="px-4 py-3 align-top">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5 text-xs">
<span className="text-sm font-semibold text-slate-900">
{priceDisplay.primary}
</span>
{priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span>
) : null}
</div>
<button
type="button"
onClick={() => onToggleItem(item.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50"
aria-label={priceButtonLabel}
title={priceButtonLabel}
>
<svg
aria-hidden="true"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M14.243 2.929a2 2 0 00-2.828 0L4 10.344V13h2.656l7.415-7.414a2 2 0 000-2.828z" />
<path
fillRule="evenodd"
d="M3.25 15a1.75 1.75 0 001.75 1.75h10A1.75 1.75 0 0016.75 15v-4a.75.75 0 00-1.5 0v4a.25.25 0 01-.25.25h-10a.25.25 0 01-.25-.25v-10a.25.25 0 01.25-.25h4a.75.75 0 000-1.5h-4A1.75 1.75 0 003.25 5v10z"
clipRule="evenodd"
/>
</svg>
<span className="sr-only">{priceButtonLabel}</span>
</button>
</div>
</td>
</tr>
{isExpanded ? (
<tr className="bg-slate-50">
<td colSpan={totalColumns} className="px-4 py-5">
<ItemPricesPanel itemId={item.id} />
</td>
</tr>
) : null}
</Fragment>
);
})}
</tbody>
</table>
</div>
</div>
<div className="space-y-4 md:hidden">
{items.map((item) => {
const isExpanded = expandedItemId === item.id;
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
return (
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<h3 className="text-lg font-semibold text-slate-900">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</h3>
</div>
</div>
{item.note ? (
<p className="mt-1 text-sm text-slate-600">Note: {item.note}</p>
) : null}
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex text-xs font-medium text-blue-600 hover:text-blue-700"
>
View source
</a>
) : null}
<div className="mt-3 space-y-2">
{visibleAttributes.map((attribute) => (
<div key={attribute} className="flex justify-between text-xs text-slate-600">
<span className="font-semibold uppercase tracking-wide text-slate-500">
{attribute}
</span>
<span className="text-right">
{formatAttributeValue(attributes[attribute])}
</span>
</div>
))}
</div>
<div className="mt-3 flex flex-col gap-0.5 text-xs">
<span className="text-sm font-semibold text-slate-900">{priceDisplay.primary}</span>
{priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span>
) : null}
</div>
<button
type="button"
onClick={() => onToggleItem(item.id)}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md border border-blue-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:bg-blue-50"
>
<svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M14.243 2.929a2 2 0 00-2.828 0L4 10.344V13h2.656l7.415-7.414a2 2 0 000-2.828z" />
<path
fillRule="evenodd"
d="M3.25 15a1.75 1.75 0 001.75 1.75h10A1.75 1.75 0 0016.75 15v-4a.75.75 0 00-1.5 0v4a.25.25 0 01-.25.25h-10a.25.25 0 01-.25-.25v-10a.25.25 0 01.25-.25h4a.75.75 0 000-1.5h-4A1.75 1.75 0 003.25 5v10z"
clipRule="evenodd"
/>
</svg>
{priceButtonLabel}
</button>
{isExpanded ? (
<div className="mt-4">
<ItemPricesPanel itemId={item.id} />
</div>
) : null}
</div>
);
})}
</div>
</section>
<section className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-6">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
Quick Add Item
</h3>
<p className="mt-2 text-sm text-slate-500">
Paste a product link to pre-fill details or switch to manual entry.
</p>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-end md:gap-6">
<div className="flex-1">
<label className="text-xs font-medium text-slate-600" htmlFor="quick-item-url">
Item URL
</label>
<input
id="quick-item-url"
type="url"
value={quickUrl}
onChange={(event) => onQuickUrlChange(event.target.value)}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
placeholder="https://example.com/product"
/>
{quickError ? (
<p className="mt-2 text-xs font-medium text-red-600">{quickError}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onAddUrl}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Add from URL
</button>
<button
type="button"
onClick={onAddManual}
className="rounded-md border border-blue-600 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:bg-blue-50"
>
Add Manually
</button>
</div>
</div>
</section>
</>
);
}
@@ -0,0 +1,93 @@
import { useMemo, useState } from 'react';
import { TrackedAttributesModal } from '../../components/tracked-attributes-modal';
interface TrackedAttributesSectionProps {
attributes: string[];
availableAttributes: string[];
isUpdating: boolean;
onUpdate: (attributes: string[]) => Promise<void>;
}
export function TrackedAttributesSection({
attributes,
availableAttributes,
isUpdating,
onUpdate
}: TrackedAttributesSectionProps) {
const [isModalOpen, setModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const hasAttributes = attributes.length > 0;
const sortedAttributes = useMemo(
() => [...attributes].sort((a, b) => a.localeCompare(b)),
[attributes]
);
const openModal = () => {
setErrorMessage(null);
setModalOpen(true);
};
const closeModal = () => {
if (isUpdating) {
return;
}
setErrorMessage(null);
setModalOpen(false);
};
const handleSubmit = async (nextAttributes: string[]) => {
setErrorMessage(null);
try {
await onUpdate(nextAttributes);
setModalOpen(false);
} catch (submitError) {
setErrorMessage((submitError as Error).message);
}
};
return (
<div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">
Tracked Attributes
</h2>
<button
type="button"
onClick={openModal}
className="text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:text-blue-700"
disabled={isUpdating}
>
Edit
</button>
</div>
<p className="mt-2 text-xs text-slate-500">
Select which item attributes should be highlighted for this project.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{hasAttributes ? (
sortedAttributes.map((attribute) => (
<span
key={attribute}
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600"
>
{attribute}
</span>
))
) : (
<span className="text-sm text-slate-400">No attributes defined yet.</span>
)}
</div>
<TrackedAttributesModal
isOpen={isModalOpen}
availableAttributes={availableAttributes}
initialSelection={attributes}
onClose={closeModal}
onSubmit={handleSubmit}
isSubmitting={isUpdating}
errorMessage={errorMessage}
/>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { FormEvent, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
useCreateProjectMutation,
useProjectsQuery
} from '../query/projects';
function ProjectsPage() {
const { data, isLoading, isError, error } = useProjectsQuery();
const createProjectMutation = useCreateProjectMutation();
const navigate = useNavigate();
const [newProjectName, setNewProjectName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const projects = data?.data ?? [];
const listError = isError ? (error as Error).message : null;
const projectCountLabel = useMemo(() => {
if (isLoading) {
return 'Loading projects…';
}
if (!projects.length) {
return 'No projects yet use the button below to create your first project.';
}
return `${projects.length} project${projects.length === 1 ? '' : 's'} found.`;
}, [isLoading, projects.length]);
const handleCreateProjectSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedName = newProjectName.trim();
if (!trimmedName) {
setCreateError('Project name is required.');
return;
}
setCreateError(null);
try {
const project = await createProjectMutation.mutateAsync({
name: trimmedName
});
setNewProjectName('');
navigate(`/projects/${project.id}`);
} catch (submitError) {
setCreateError((submitError as Error).message);
}
};
return (
<div className="flex flex-col gap-10">
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<header className="mb-4">
<h1 className="text-xl font-semibold text-slate-900">Create a new project</h1>
<p className="text-sm text-slate-500">
Enter a project name to start comparing items instantly.
</p>
</header>
<form
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4"
onSubmit={handleCreateProjectSubmit}
>
<label className="flex w-full flex-col gap-1 sm:flex-1 sm:max-w-xl" htmlFor="new-project-name">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
Project Name
</span>
<input
id="new-project-name"
type="text"
value={newProjectName}
onChange={(event) => setNewProjectName(event.target.value)}
placeholder="Home Office Upgrade"
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
disabled={createProjectMutation.isPending}
/>
</label>
<button
type="submit"
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400 sm:flex-none"
disabled={createProjectMutation.isPending}
>
{createProjectMutation.isPending ? 'Creating…' : 'Add Project'}
</button>
</form>
{createError ? (
<p className="mt-3 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{createError}</p>
) : null}
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<header className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Projects</h2>
<p className="text-sm text-slate-500">{projectCountLabel}</p>
</div>
</header>
{listError ? (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{listError}</p>
) : null}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Name
</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Status
</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Attributes
</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wide text-slate-500">
Updated
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{projects.map((project) => (
<tr key={project.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<Link
to={`/projects/${project.id}`}
className="font-medium text-blue-600 hover:text-blue-700"
>
{project.name}
</Link>
{project.description ? (
<p className="text-xs text-slate-500">{project.description}</p>
) : null}
</td>
<td className="px-4 py-3 capitalize">{project.status}</td>
<td className="px-4 py-3">
{project.attributes.length
? project.attributes.join(', ')
: <span className="text-slate-400"></span>}
</td>
<td className="px-4 py-3 text-xs text-slate-500">
{new Date(project.updatedAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
export default ProjectsPage;
+44
View File
@@ -0,0 +1,44 @@
import {
createItemPrice,
fetchItemPrices,
type CreateItemPricePayload,
type ItemPricesResponse
} from '../api/item-prices';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const DEFAULT_LIMIT = 50;
export const itemPricesKeys = {
all: ['itemPrices'] as const,
list: (itemId: string) => [...itemPricesKeys.all, itemId, 'list'] as const
};
export const useItemPricesQuery = (
itemId: string | undefined,
enabled: boolean
) =>
useQuery<ItemPricesResponse>({
queryKey: itemId ? itemPricesKeys.list(itemId) : ['itemPrices', 'missing'],
enabled: enabled && Boolean(itemId),
queryFn: ({ signal }) => fetchItemPrices(itemId!, { limit: DEFAULT_LIMIT, offset: 0, signal })
});
export const useCreateItemPriceMutation = (itemId: string | undefined) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateItemPricePayload) => {
if (!itemId) {
throw new Error('itemId is required to create a price');
}
return createItemPrice(itemId, payload);
},
onSuccess: () => {
if (!itemId) {
return;
}
queryClient.invalidateQueries({ queryKey: itemPricesKeys.list(itemId) });
}
});
};
+100
View File
@@ -0,0 +1,100 @@
import {
createProject,
fetchProject,
fetchProjects,
updateProject,
type CreateProjectPayload,
type Project,
type ProjectsListResponse,
type UpdateProjectPayload
} from '../api/projects';
import {
createItem,
fetchProjectItems,
type CreateItemPayload,
type ProjectItemsResponse
} from '../api/items';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const DEFAULT_LIMIT = 50;
export const projectsKeys = {
all: ['projects'] as const,
list: () => [...projectsKeys.all, 'list'] as const,
detail: (projectId: string) =>
[...projectsKeys.all, 'detail', projectId] as const,
items: (projectId: string) =>
[...projectsKeys.detail(projectId), 'items'] as const
};
export const useProjectsQuery = () =>
useQuery<ProjectsListResponse>({
queryKey: projectsKeys.list(),
queryFn: ({ signal }) => fetchProjects({ limit: DEFAULT_LIMIT, offset: 0, signal })
});
export const useProjectQuery = (projectId: string | undefined) =>
useQuery<Project>({
queryKey: projectId ? projectsKeys.detail(projectId) : ['project', 'missing'],
enabled: Boolean(projectId),
queryFn: ({ signal }) => fetchProject(projectId!, { signal })
});
export const useProjectItemsQuery = (projectId: string | undefined) =>
useQuery<ProjectItemsResponse>({
queryKey: projectId ? projectsKeys.items(projectId) : ['project', 'items', 'missing'],
enabled: Boolean(projectId),
queryFn: ({ signal }) =>
fetchProjectItems(projectId!, { limit: DEFAULT_LIMIT, offset: 0, signal })
});
export const useCreateProjectMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateProjectPayload) => createProject(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectsKeys.list() });
}
});
};
export const useUpdateProjectMutation = (projectId: string | undefined) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateProjectPayload) => {
if (!projectId) {
throw new Error('projectId is required to update a project');
}
return updateProject(projectId, payload);
},
onSuccess: () => {
if (!projectId) {
return;
}
queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
queryClient.invalidateQueries({ queryKey: projectsKeys.list() });
}
});
};
export const useCreateItemMutation = (projectId: string | undefined) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateItemPayload) => {
if (!projectId) {
throw new Error('projectId is required to create an item');
}
return createItem(projectId, payload);
},
onSuccess: () => {
if (!projectId) {
return;
}
queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) });
queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) });
}
});
};
-14
View File
@@ -1,14 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getHealth, type HealthResponse } from '../api/health';
const healthQueryKeys = {
all: ['health'] as const
};
export function useHealthQuery() {
return useQuery<HealthResponse>({
queryKey: healthQueryKeys.all,
queryFn: getHealth,
staleTime: 30_000
});
}
+7 -4
View File
@@ -1,13 +1,16 @@
import { Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import AppLayout from '../layouts/app-layout';
import HealthPage from '../pages/health-page';
import ProjectsPage from '../pages/projects-page';
import { ProjectDetailPage } from '../pages/project-detail/project-detail-page';
function AppRoutes() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HealthPage />} />
<Route path="*" element={<HealthPage />} />
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
</Route>
</Routes>
);