mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
1st approach for projects, items, db structure
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
import { apiFetch } from './http-client';
|
|
||||||
|
|
||||||
export type HealthResponse = {
|
|
||||||
status: string;
|
|
||||||
service: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getHealth() {
|
|
||||||
return apiFetch<HealthResponse>('/health');
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||||
|
|
||||||
type ApiFetchOptions = Omit<RequestInit, 'body'> & {
|
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> {
|
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);
|
const response = await fetch(`${API_BASE_URL}${path}`, init);
|
||||||
|
|
||||||
if (!response.ok) {
|
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>;
|
return response.json() as Promise<TResponse>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,105 @@
|
|||||||
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 = [
|
const navItems: Array<{ label: string; to: string; end?: boolean }> = [
|
||||||
{ label: 'Health Check', to: '/' },
|
{ label: 'Projects', to: '/projects' }
|
||||||
// future nav items can be added here
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function AppLayout() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-50 text-slate-800">
|
<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">
|
{!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">
|
<div className="mb-8">
|
||||||
<span className="block text-sm font-semibold uppercase tracking-wide text-slate-400">
|
<span className="block text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
BestChoice
|
BestChoice
|
||||||
@@ -16,12 +107,14 @@ function AppLayout() {
|
|||||||
<h1 className="text-2xl font-semibold text-slate-900">Control Center</h1>
|
<h1 className="text-2xl font-semibold text-slate-900">Control Center</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-1 flex-col gap-2">
|
<nav className="flex flex-1 flex-col">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end
|
end={item.end}
|
||||||
|
onClick={handleNavLinkClick}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
'rounded-md px-3 py-2 text-sm font-medium transition',
|
'rounded-md px-3 py-2 text-sm font-medium transition',
|
||||||
@@ -34,20 +127,89 @@ function AppLayout() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</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'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>
|
</nav>
|
||||||
|
|
||||||
<footer className="mt-8 text-xs text-slate-400">
|
<footer className="mt-8 text-xs text-slate-400">
|
||||||
<p>© {new Date().getFullYear()} BestChoice</p>
|
<p>© {new Date().getFullYear()} BestChoice</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="border-b border-slate-200 bg-white px-8 py-6 shadow-sm">
|
<header className="border-b border-slate-200 bg-white px-4 py-4 shadow-sm md:px-8 md:py-6">
|
||||||
<h2 className="text-xl font-semibold text-slate-900">Dashboard</h2>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-slate-500">Monitor service health and status</p>
|
<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>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 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() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<HealthPage />} />
|
<Route index element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="*" element={<HealthPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
|
<Route path="projects/:projectId" element={<ProjectDetailPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import apiRouter from './routes/api-router.js';
|
import apiRouter from './routes/api-router.js';
|
||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
|
import { errorHandler } from './middleware/error-handler.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(env.apiBasePath, apiRouter);
|
app.use(env.apiBasePath, apiRouter);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
|
|
||||||
export function getHealth(_req: Request, res: Response) {
|
|
||||||
res.json({ status: 'ok', service: 'best-choice-api' });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
createItemPrice as createItemPriceRepo,
|
||||||
|
deleteItemPrice as deleteItemPriceRepo,
|
||||||
|
getItemPriceById,
|
||||||
|
listItemPrices as listItemPricesRepo,
|
||||||
|
updateItemPrice as updateItemPriceRepo
|
||||||
|
} from '../db/item-prices-repository.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { resolveUrlId } from './url-helpers.js';
|
||||||
|
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||||
|
import {
|
||||||
|
parseItemPriceCreatePayload,
|
||||||
|
parseItemPriceUpdatePayload,
|
||||||
|
parsePriceConditionFilter
|
||||||
|
} from '../validation/item-prices.js';
|
||||||
|
|
||||||
|
export const listItemPrices = async (req: Request, res: Response) => {
|
||||||
|
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||||
|
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||||
|
const condition = parsePriceConditionFilter(
|
||||||
|
(req.query as Record<string, unknown>).condition
|
||||||
|
);
|
||||||
|
|
||||||
|
const prices = await listItemPricesRepo({
|
||||||
|
itemId,
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
condition
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: prices,
|
||||||
|
meta: {
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
count: prices.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createItemPrice = async (req: Request, res: Response) => {
|
||||||
|
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||||
|
const payload = parseItemPriceCreatePayload(req.body);
|
||||||
|
|
||||||
|
let sourceUrlId: string | null = null;
|
||||||
|
if (payload.sourceType === 'url') {
|
||||||
|
sourceUrlId = await resolveUrlId({
|
||||||
|
sourceUrlId: payload.sourceUrlId,
|
||||||
|
sourceUrl: payload.sourceUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const price = await createItemPriceRepo({
|
||||||
|
itemId,
|
||||||
|
condition: payload.condition,
|
||||||
|
amount: payload.amount,
|
||||||
|
currency: payload.currency,
|
||||||
|
sourceType: payload.sourceType,
|
||||||
|
sourceUrlId,
|
||||||
|
sourceNote: payload.sourceNote,
|
||||||
|
note: payload.note,
|
||||||
|
observedAt: payload.observedAt,
|
||||||
|
isPrimary: payload.isPrimary
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: price });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === '23503'
|
||||||
|
) {
|
||||||
|
throw new HttpError(404, 'Item not found');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemPrice = async (req: Request, res: Response) => {
|
||||||
|
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||||
|
const price = await getItemPriceById(priceId);
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
throw new HttpError(404, 'Price not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: price });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItemPrice = async (req: Request, res: Response) => {
|
||||||
|
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||||
|
const payload = parseItemPriceUpdatePayload(req.body);
|
||||||
|
|
||||||
|
let sourceUrlId: string | null | undefined;
|
||||||
|
const payloadRecord = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
const hasSourceUrlField = Object.prototype.hasOwnProperty.call(
|
||||||
|
payloadRecord,
|
||||||
|
'sourceUrl'
|
||||||
|
);
|
||||||
|
const hasSourceUrlIdField = Object.prototype.hasOwnProperty.call(
|
||||||
|
payloadRecord,
|
||||||
|
'sourceUrlId'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (payload.sourceType === 'manual') {
|
||||||
|
sourceUrlId = null;
|
||||||
|
} else if (payload.sourceType === 'url' || hasSourceUrlField || hasSourceUrlIdField) {
|
||||||
|
sourceUrlId = await resolveUrlId({
|
||||||
|
sourceUrlId: payload.sourceUrlId ?? null,
|
||||||
|
sourceUrl: payload.sourceUrl ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = await updateItemPriceRepo(priceId, {
|
||||||
|
condition: payload.condition,
|
||||||
|
amount: payload.amount,
|
||||||
|
currency: payload.currency,
|
||||||
|
sourceType: payload.sourceType,
|
||||||
|
sourceUrlId,
|
||||||
|
sourceNote: payload.sourceNote,
|
||||||
|
note: payload.note,
|
||||||
|
observedAt: payload.observedAt,
|
||||||
|
isPrimary: payload.isPrimary
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!price) {
|
||||||
|
throw new HttpError(404, 'Price not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: price });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItemPrice = async (req: Request, res: Response) => {
|
||||||
|
const priceId = parseUuid(req.params.priceId, 'priceId');
|
||||||
|
const deleted = await deleteItemPriceRepo(priceId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new HttpError(404, 'Price not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
createItem as createItemRepo,
|
||||||
|
deleteItem as deleteItemRepo,
|
||||||
|
getItemById,
|
||||||
|
listItems as listItemsRepo,
|
||||||
|
updateItem as updateItemRepo
|
||||||
|
} from '../db/items-repository.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { resolveUrlId } from './url-helpers.js';
|
||||||
|
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||||
|
import {
|
||||||
|
parseItemCreatePayload,
|
||||||
|
parseItemStatusFilter,
|
||||||
|
parseItemUpdatePayload
|
||||||
|
} from '../validation/items.js';
|
||||||
|
|
||||||
|
export const listItems = async (req: Request, res: Response) => {
|
||||||
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
|
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||||
|
const status = parseItemStatusFilter((req.query as Record<string, unknown>).status);
|
||||||
|
|
||||||
|
const items = await listItemsRepo({
|
||||||
|
projectId,
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
count: items.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createItem = async (req: Request, res: Response) => {
|
||||||
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
|
const payload = parseItemCreatePayload(req.body);
|
||||||
|
|
||||||
|
const sourceUrlId = await resolveUrlId({
|
||||||
|
sourceUrlId: payload.sourceUrlId,
|
||||||
|
sourceUrl: payload.sourceUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await createItemRepo({
|
||||||
|
projectId,
|
||||||
|
manufacturer: payload.manufacturer,
|
||||||
|
model: payload.model,
|
||||||
|
sourceUrlId,
|
||||||
|
status: payload.status,
|
||||||
|
note: payload.note,
|
||||||
|
attributes: payload.attributes
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: item });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as { code: string }).code === '23503'
|
||||||
|
) {
|
||||||
|
throw new HttpError(404, 'Project not found');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItem = async (req: Request, res: Response) => {
|
||||||
|
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||||
|
const item = await getItemById(itemId);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new HttpError(404, 'Item not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: item });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateItem = async (req: Request, res: Response) => {
|
||||||
|
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||||
|
const payload = parseItemUpdatePayload(req.body);
|
||||||
|
|
||||||
|
let sourceUrlId: string | null | undefined;
|
||||||
|
if ('sourceUrl' in payload || 'sourceUrlId' in payload) {
|
||||||
|
sourceUrlId = await resolveUrlId({
|
||||||
|
sourceUrlId: payload.sourceUrlId ?? null,
|
||||||
|
sourceUrl: payload.sourceUrl ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await updateItemRepo(itemId, {
|
||||||
|
manufacturer: payload.manufacturer,
|
||||||
|
model: payload.model,
|
||||||
|
sourceUrlId,
|
||||||
|
status: payload.status,
|
||||||
|
note: payload.note,
|
||||||
|
attributes: payload.attributes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new HttpError(404, 'Item not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: item });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItem = async (req: Request, res: Response) => {
|
||||||
|
const itemId = parseUuid(req.params.itemId, 'itemId');
|
||||||
|
const deleted = await deleteItemRepo(itemId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new HttpError(404, 'Item not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
createProject as createProjectRepo,
|
||||||
|
deleteProject as deleteProjectRepo,
|
||||||
|
getProjectById,
|
||||||
|
listProjects as listProjectsRepo,
|
||||||
|
updateProject as updateProjectRepo
|
||||||
|
} from '../db/projects-repository.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { parsePaginationParams, parseUuid } from '../validation/common.js';
|
||||||
|
import {
|
||||||
|
parseProjectCreatePayload,
|
||||||
|
parseProjectStatusFilter,
|
||||||
|
parseProjectUpdatePayload
|
||||||
|
} from '../validation/projects.js';
|
||||||
|
|
||||||
|
export const listProjects = async (req: Request, res: Response) => {
|
||||||
|
const pagination = parsePaginationParams(req.query as Record<string, unknown>);
|
||||||
|
const status = parseProjectStatusFilter((req.query as Record<string, unknown>).status);
|
||||||
|
|
||||||
|
const projects = await listProjectsRepo({
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: projects,
|
||||||
|
meta: {
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
count: projects.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProject = async (req: Request, res: Response) => {
|
||||||
|
const payload = parseProjectCreatePayload(req.body);
|
||||||
|
const project = await createProjectRepo({
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
status: payload.status,
|
||||||
|
attributes: payload.projectAttributes,
|
||||||
|
priorityRules: payload.projectPriorityRules
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: project });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProject = async (req: Request, res: Response) => {
|
||||||
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
|
const project = await getProjectById(projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new HttpError(404, 'Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: project });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProject = async (req: Request, res: Response) => {
|
||||||
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
|
const payload = parseProjectUpdatePayload(req.body);
|
||||||
|
|
||||||
|
const project = await updateProjectRepo(projectId, {
|
||||||
|
name: payload.name,
|
||||||
|
description: payload.description,
|
||||||
|
status: payload.status,
|
||||||
|
attributes: payload.projectAttributes,
|
||||||
|
priorityRules: payload.projectPriorityRules
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new HttpError(404, 'Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: project });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProject = async (req: Request, res: Response) => {
|
||||||
|
const projectId = parseUuid(req.params.projectId, 'projectId');
|
||||||
|
const deleted = await deleteProjectRepo(projectId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new HttpError(404, 'Project not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createUrl, findUrlById } from '../db/urls-repository.js';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { normalizeUrl } from '../utils/url-normalizer.js';
|
||||||
|
|
||||||
|
export const resolveUrlId = async (params: {
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
}) => {
|
||||||
|
let urlId = params.sourceUrlId ?? null;
|
||||||
|
|
||||||
|
if (params.sourceUrl) {
|
||||||
|
const normalized = normalizeUrl(params.sourceUrl);
|
||||||
|
const url = await createUrl({ url: normalized });
|
||||||
|
urlId = url.id;
|
||||||
|
} else if (urlId) {
|
||||||
|
const existing = await findUrlById(urlId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new HttpError(400, 'sourceUrlId does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlId;
|
||||||
|
};
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { pool, query } from './pool.js';
|
||||||
|
|
||||||
|
const priceColumns = `
|
||||||
|
p.id,
|
||||||
|
p.item_id,
|
||||||
|
p.condition,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.source_type,
|
||||||
|
p.source_url_id,
|
||||||
|
p.source_note,
|
||||||
|
p.note,
|
||||||
|
p.observed_at,
|
||||||
|
p.is_primary,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
u.url AS source_url
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface ItemPriceRow {
|
||||||
|
id: string;
|
||||||
|
item_id: string;
|
||||||
|
condition: 'new' | 'used';
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
source_type: 'url' | 'manual';
|
||||||
|
source_url_id: string | null;
|
||||||
|
source_note: string | null;
|
||||||
|
note: string | null;
|
||||||
|
observed_at: Date;
|
||||||
|
is_primary: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
source_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemPriceRecord {
|
||||||
|
id: string;
|
||||||
|
itemId: string;
|
||||||
|
condition: 'new' | 'used';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
sourceType: 'url' | 'manual';
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
sourceNote: string | null;
|
||||||
|
note: string | null;
|
||||||
|
observedAt: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapItemPriceRow = (row: ItemPriceRow): ItemPriceRecord => ({
|
||||||
|
id: row.id,
|
||||||
|
itemId: row.item_id,
|
||||||
|
condition: row.condition,
|
||||||
|
amount: Number(row.amount),
|
||||||
|
currency: row.currency,
|
||||||
|
sourceType: row.source_type,
|
||||||
|
sourceUrlId: row.source_url_id,
|
||||||
|
sourceUrl: row.source_url,
|
||||||
|
sourceNote: row.source_note,
|
||||||
|
note: row.note,
|
||||||
|
observedAt: row.observed_at.toISOString(),
|
||||||
|
isPrimary: row.is_primary,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ListItemPricesOptions {
|
||||||
|
itemId: string;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
condition?: 'new' | 'used';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listItemPrices = async (
|
||||||
|
options: ListItemPricesOptions
|
||||||
|
): Promise<ItemPriceRecord[]> => {
|
||||||
|
const params: unknown[] = [options.itemId];
|
||||||
|
const filters: string[] = ['p.item_id = $1'];
|
||||||
|
|
||||||
|
if (options.condition) {
|
||||||
|
params.push(options.condition);
|
||||||
|
filters.push(`p.condition = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(options.limit);
|
||||||
|
const limitIndex = params.length;
|
||||||
|
params.push(options.offset);
|
||||||
|
const offsetIndex = params.length;
|
||||||
|
|
||||||
|
const result = await query<ItemPriceRow>(
|
||||||
|
`
|
||||||
|
SELECT ${priceColumns}
|
||||||
|
FROM item_prices p
|
||||||
|
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||||
|
WHERE ${filters.join(' AND ')}
|
||||||
|
ORDER BY p.observed_at DESC
|
||||||
|
LIMIT $${limitIndex}
|
||||||
|
OFFSET $${offsetIndex}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(mapItemPriceRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateItemPriceParams {
|
||||||
|
itemId: string;
|
||||||
|
condition: 'new' | 'used';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
sourceType: 'url' | 'manual';
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceNote: string | null;
|
||||||
|
note: string | null;
|
||||||
|
observedAt?: string;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createItemPrice = async (
|
||||||
|
params: CreateItemPriceParams
|
||||||
|
): Promise<ItemPriceRecord> => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
if (params.isPrimary) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE item_prices
|
||||||
|
SET is_primary = FALSE
|
||||||
|
WHERE item_id = $1 AND condition = $2
|
||||||
|
`,
|
||||||
|
[params.itemId, params.condition]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
INSERT INTO item_prices (
|
||||||
|
item_id,
|
||||||
|
condition,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
source_type,
|
||||||
|
source_url_id,
|
||||||
|
source_note,
|
||||||
|
note,
|
||||||
|
observed_at,
|
||||||
|
is_primary
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, NOW()), $10)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
params.itemId,
|
||||||
|
params.condition,
|
||||||
|
params.amount,
|
||||||
|
params.currency,
|
||||||
|
params.sourceType,
|
||||||
|
params.sourceUrlId,
|
||||||
|
params.sourceNote,
|
||||||
|
params.note,
|
||||||
|
params.observedAt ?? null,
|
||||||
|
params.isPrimary
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertedId = result.rows[0]?.id;
|
||||||
|
if (!insertedId) {
|
||||||
|
throw new Error('Failed to create item price');
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await client.query<ItemPriceRow>(
|
||||||
|
`
|
||||||
|
SELECT ${priceColumns}
|
||||||
|
FROM item_prices p
|
||||||
|
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
`,
|
||||||
|
[insertedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return mapItemPriceRow(record.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemPriceById = async (id: string): Promise<ItemPriceRecord | null> => {
|
||||||
|
const result = await query<ItemPriceRow>(
|
||||||
|
`
|
||||||
|
SELECT ${priceColumns}
|
||||||
|
FROM item_prices p
|
||||||
|
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapItemPriceRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateItemPriceParams {
|
||||||
|
condition?: 'new' | 'used';
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
sourceType?: 'url' | 'manual';
|
||||||
|
sourceUrlId?: string | null;
|
||||||
|
sourceNote?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
observedAt?: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateItemPrice = async (
|
||||||
|
id: string,
|
||||||
|
params: UpdateItemPriceParams
|
||||||
|
): Promise<ItemPriceRecord | null> => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const existingResult = await client.query<ItemPriceRow>(
|
||||||
|
`
|
||||||
|
SELECT ${priceColumns}
|
||||||
|
FROM item_prices p
|
||||||
|
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = existingResult.rows[0];
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (params.condition !== undefined) {
|
||||||
|
values.push(params.condition);
|
||||||
|
fields.push(`condition = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.amount !== undefined) {
|
||||||
|
values.push(params.amount);
|
||||||
|
fields.push(`amount = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.currency !== undefined) {
|
||||||
|
values.push(params.currency);
|
||||||
|
fields.push(`currency = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.sourceType !== undefined) {
|
||||||
|
values.push(params.sourceType);
|
||||||
|
fields.push(`source_type = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.sourceUrlId !== undefined) {
|
||||||
|
values.push(params.sourceUrlId);
|
||||||
|
fields.push(`source_url_id = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.sourceNote !== undefined) {
|
||||||
|
values.push(params.sourceNote);
|
||||||
|
fields.push(`source_note = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.note !== undefined) {
|
||||||
|
values.push(params.note);
|
||||||
|
fields.push(`note = $${values.length}`);
|
||||||
|
}
|
||||||
|
if (params.observedAt !== undefined) {
|
||||||
|
values.push(params.observedAt);
|
||||||
|
fields.push(`observed_at = COALESCE($${values.length}, NOW())`);
|
||||||
|
}
|
||||||
|
if (params.isPrimary !== undefined) {
|
||||||
|
values.push(params.isPrimary);
|
||||||
|
fields.push(`is_primary = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
values.push(id);
|
||||||
|
const idIndex = values.length;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE item_prices
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $${idIndex}
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCondition = params.condition ?? existing.condition;
|
||||||
|
const newIsPrimary = params.isPrimary ?? existing.is_primary;
|
||||||
|
|
||||||
|
if (newIsPrimary) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE item_prices
|
||||||
|
SET is_primary = FALSE
|
||||||
|
WHERE item_id = $1 AND condition = $2 AND id <> $3
|
||||||
|
`,
|
||||||
|
[existing.item_id, newCondition, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResult = await client.query<ItemPriceRow>(
|
||||||
|
`
|
||||||
|
SELECT ${priceColumns}
|
||||||
|
FROM item_prices p
|
||||||
|
LEFT JOIN urls u ON u.id = p.source_url_id
|
||||||
|
WHERE p.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return mapItemPriceRow(updatedResult.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItemPrice = async (id: string): Promise<boolean> => {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
DELETE FROM item_prices
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { query } from './pool.js';
|
||||||
|
|
||||||
|
const itemColumns = `
|
||||||
|
i.id,
|
||||||
|
i.project_id,
|
||||||
|
i.manufacturer,
|
||||||
|
i.model,
|
||||||
|
i.source_url_id,
|
||||||
|
i.status,
|
||||||
|
i.note,
|
||||||
|
i.attributes,
|
||||||
|
i.created_at,
|
||||||
|
i.updated_at,
|
||||||
|
u.url AS source_url,
|
||||||
|
price_summary.min_amount AS price_min_amount,
|
||||||
|
price_summary.max_amount AS price_max_amount,
|
||||||
|
price_summary.total_count AS price_count,
|
||||||
|
price_summary.currency AS price_currency,
|
||||||
|
price_summary.currency_count AS price_currency_count
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface ItemRow {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
manufacturer: string | null;
|
||||||
|
model: string;
|
||||||
|
source_url_id: string | null;
|
||||||
|
status: 'active' | 'rejected';
|
||||||
|
note: string | null;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
source_url: string | null;
|
||||||
|
price_min_amount: string | null;
|
||||||
|
price_max_amount: string | null;
|
||||||
|
price_count: number | null;
|
||||||
|
price_currency: string | null;
|
||||||
|
price_currency_count: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemRecord {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
manufacturer: string | null;
|
||||||
|
model: string;
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
status: 'active' | 'rejected';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapItemRow = (row: ItemRow): ItemRecord => {
|
||||||
|
const priceCount = row.price_count ?? 0;
|
||||||
|
const priceSummary: ItemPriceSummary | null =
|
||||||
|
priceCount > 0 && row.price_min_amount !== null && row.price_max_amount !== null
|
||||||
|
? {
|
||||||
|
minAmount: Number(row.price_min_amount),
|
||||||
|
maxAmount: Number(row.price_max_amount),
|
||||||
|
currency: row.price_currency,
|
||||||
|
priceCount,
|
||||||
|
hasMixedCurrency: (row.price_currency_count ?? 0) > 1
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.project_id,
|
||||||
|
manufacturer: row.manufacturer,
|
||||||
|
model: row.model,
|
||||||
|
sourceUrlId: row.source_url_id,
|
||||||
|
sourceUrl: row.source_url,
|
||||||
|
status: row.status,
|
||||||
|
note: row.note,
|
||||||
|
attributes: row.attributes ?? {},
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
priceSummary
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ListItemsOptions {
|
||||||
|
projectId: string;
|
||||||
|
status?: 'active' | 'rejected';
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listItems = async (options: ListItemsOptions): Promise<ItemRecord[]> => {
|
||||||
|
const params: unknown[] = [options.projectId];
|
||||||
|
const filters: string[] = ['i.project_id = $1'];
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
params.push(options.status);
|
||||||
|
filters.push(`i.status = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(options.limit);
|
||||||
|
const limitIndex = params.length;
|
||||||
|
params.push(options.offset);
|
||||||
|
const offsetIndex = params.length;
|
||||||
|
|
||||||
|
const result = await query<ItemRow>(
|
||||||
|
`
|
||||||
|
SELECT ${itemColumns}
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN urls u ON u.id = i.source_url_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
MIN(ip.amount) AS min_amount,
|
||||||
|
MAX(ip.amount) AS max_amount,
|
||||||
|
COUNT(*)::INT AS total_count,
|
||||||
|
COUNT(DISTINCT ip.currency)::INT AS currency_count,
|
||||||
|
MIN(ip.currency) AS currency
|
||||||
|
FROM item_prices ip
|
||||||
|
WHERE ip.item_id = i.id
|
||||||
|
) price_summary ON TRUE
|
||||||
|
WHERE ${filters.join(' AND ')}
|
||||||
|
ORDER BY i.created_at DESC
|
||||||
|
LIMIT $${limitIndex}
|
||||||
|
OFFSET $${offsetIndex}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(mapItemRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateItemParams {
|
||||||
|
projectId: string;
|
||||||
|
manufacturer: string | null;
|
||||||
|
model: string;
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
status: 'active' | 'rejected';
|
||||||
|
note: string | null;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createItem = async (params: CreateItemParams): Promise<ItemRecord> => {
|
||||||
|
const result = await query<{ id: string }>(
|
||||||
|
`
|
||||||
|
INSERT INTO items (
|
||||||
|
project_id,
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
source_url_id,
|
||||||
|
status,
|
||||||
|
note,
|
||||||
|
attributes
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
params.projectId,
|
||||||
|
params.manufacturer,
|
||||||
|
params.model,
|
||||||
|
params.sourceUrlId,
|
||||||
|
params.status,
|
||||||
|
params.note,
|
||||||
|
params.attributes
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertedId = result.rows[0]?.id;
|
||||||
|
if (!insertedId) {
|
||||||
|
throw new Error('Failed to create item');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await getItemById(insertedId);
|
||||||
|
if (!inserted) {
|
||||||
|
throw new Error('Created item could not be loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getItemById = async (id: string): Promise<ItemRecord | null> => {
|
||||||
|
const result = await query<ItemRow>(
|
||||||
|
`
|
||||||
|
SELECT ${itemColumns}
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN urls u ON u.id = i.source_url_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
MIN(ip.amount) AS min_amount,
|
||||||
|
MAX(ip.amount) AS max_amount,
|
||||||
|
COUNT(*)::INT AS total_count,
|
||||||
|
COUNT(DISTINCT ip.currency)::INT AS currency_count,
|
||||||
|
MIN(ip.currency) AS currency
|
||||||
|
FROM item_prices ip
|
||||||
|
WHERE ip.item_id = i.id
|
||||||
|
) price_summary ON TRUE
|
||||||
|
WHERE i.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapItemRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateItemParams {
|
||||||
|
manufacturer?: string | null;
|
||||||
|
model?: string;
|
||||||
|
sourceUrlId?: string | null;
|
||||||
|
status?: 'active' | 'rejected';
|
||||||
|
note?: string | null;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateItem = async (
|
||||||
|
id: string,
|
||||||
|
params: UpdateItemParams
|
||||||
|
): Promise<ItemRecord | null> => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (params.manufacturer !== undefined) {
|
||||||
|
values.push(params.manufacturer);
|
||||||
|
fields.push(`manufacturer = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.model !== undefined) {
|
||||||
|
values.push(params.model);
|
||||||
|
fields.push(`model = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.sourceUrlId !== undefined) {
|
||||||
|
values.push(params.sourceUrlId);
|
||||||
|
fields.push(`source_url_id = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.status !== undefined) {
|
||||||
|
values.push(params.status);
|
||||||
|
fields.push(`status = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.note !== undefined) {
|
||||||
|
values.push(params.note);
|
||||||
|
fields.push(`note = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.attributes !== undefined) {
|
||||||
|
values.push(params.attributes);
|
||||||
|
fields.push(`attributes = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return getItemById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const idParamIndex = values.length;
|
||||||
|
|
||||||
|
const result = await query<{ id: string }>(
|
||||||
|
`
|
||||||
|
UPDATE items
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $${idParamIndex}
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedId = result.rows[0]?.id;
|
||||||
|
if (!updatedId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getItemById(updatedId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteItem = async (id: string): Promise<boolean> => {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
DELETE FROM items
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: '0002-create-projects-table',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE project_status AS ENUM ('active', 'archived');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(150) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status project_status NOT NULL DEFAULT 'active',
|
||||||
|
project_attributes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||||
|
project_priority_rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: '0003-create-urls-table',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS urls (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
note TEXT,
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
has_price BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: '0004-create-items-table',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE item_status AS ENUM ('active', 'rejected');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
manufacturer VARCHAR(120),
|
||||||
|
model VARCHAR(150) NOT NULL,
|
||||||
|
source_url_id UUID REFERENCES urls(id) ON DELETE SET NULL,
|
||||||
|
status item_status NOT NULL DEFAULT 'active',
|
||||||
|
note TEXT,
|
||||||
|
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS items_project_id_idx ON items (project_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS items_source_url_id_idx ON items (source_url_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Migration } from './types.js';
|
||||||
|
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: '0005-create-item-prices-table',
|
||||||
|
up: async (client) => {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE price_condition AS ENUM ('new', 'used');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE price_source AS ENUM ('url', 'manual');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END$$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS item_prices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
condition price_condition NOT NULL,
|
||||||
|
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
|
||||||
|
currency CHAR(3) NOT NULL,
|
||||||
|
source_type price_source NOT NULL,
|
||||||
|
source_url_id UUID REFERENCES urls(id) ON DELETE SET NULL,
|
||||||
|
source_note TEXT,
|
||||||
|
note TEXT,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (char_length(currency) = 3),
|
||||||
|
CHECK (
|
||||||
|
(source_type = 'manual'::price_source AND source_url_id IS NULL)
|
||||||
|
OR (source_type = 'url'::price_source AND source_url_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS item_prices_item_id_idx ON item_prices (item_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS item_prices_source_url_id_idx ON item_prices (source_url_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS item_prices_observed_at_idx ON item_prices (observed_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS item_prices_primary_condition_idx
|
||||||
|
ON item_prices (item_id, condition)
|
||||||
|
WHERE is_primary = TRUE;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
import type { Migration } from './types.js';
|
import type { Migration } from './types.js';
|
||||||
import { migration as enablePgvector } from './0001-enable-pgvector.js';
|
import { migration as enablePgvector } from './0001-enable-pgvector.js';
|
||||||
|
import { migration as createProjectsTable } from './0002-create-projects-table.js';
|
||||||
|
import { migration as createUrlsTable } from './0003-create-urls-table.js';
|
||||||
|
import { migration as createItemsTable } from './0004-create-items-table.js';
|
||||||
|
import { migration as createItemPricesTable } from './0005-create-item-prices-table.js';
|
||||||
|
|
||||||
export const migrations: Migration[] = [enablePgvector];
|
export const migrations: Migration[] = [
|
||||||
|
enablePgvector,
|
||||||
|
createProjectsTable,
|
||||||
|
createUrlsTable,
|
||||||
|
createItemsTable,
|
||||||
|
createItemPricesTable,
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool, type QueryResultRow } from 'pg';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
if (!env.databaseUrl) {
|
if (!env.databaseUrl) {
|
||||||
@@ -15,4 +15,7 @@ pool.on('error', (error) => {
|
|||||||
|
|
||||||
export const getClient = () => pool.connect();
|
export const getClient = () => pool.connect();
|
||||||
|
|
||||||
export const query = (text: string, params?: unknown[]) => pool.query(text, params);
|
export const query = <T extends QueryResultRow = QueryResultRow>(
|
||||||
|
text: string,
|
||||||
|
params?: unknown[]
|
||||||
|
) => pool.query<T>(text, params);
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { query } from './pool.js';
|
||||||
|
|
||||||
|
const projectColumns = `
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
project_attributes,
|
||||||
|
project_priority_rules,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface ProjectRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
project_attributes: string[];
|
||||||
|
project_priority_rules: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
attributes: string[];
|
||||||
|
priorityRules: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapProjectRow = (row: ProjectRow): Project => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
status: row.status,
|
||||||
|
attributes: row.project_attributes ?? [],
|
||||||
|
priorityRules: row.project_priority_rules ?? [],
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ListProjectsOptions {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
status?: 'active' | 'archived';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listProjects = async (
|
||||||
|
options: ListProjectsOptions
|
||||||
|
): Promise<Project[]> => {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
if (options.status) {
|
||||||
|
params.push(options.status);
|
||||||
|
conditions.push(`status = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(options.limit);
|
||||||
|
const limitParamIndex = params.length;
|
||||||
|
params.push(options.offset);
|
||||||
|
const offsetParamIndex = params.length;
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await query<ProjectRow>(
|
||||||
|
`
|
||||||
|
SELECT ${projectColumns}
|
||||||
|
FROM projects
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${limitParamIndex}
|
||||||
|
OFFSET $${offsetParamIndex}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(mapProjectRow);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateProjectParams {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
attributes: string[];
|
||||||
|
priorityRules: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createProject = async (params: CreateProjectParams): Promise<Project> => {
|
||||||
|
const result = await query<ProjectRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO projects (
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
project_attributes,
|
||||||
|
project_priority_rules
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING ${projectColumns}
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
params.name,
|
||||||
|
params.description,
|
||||||
|
params.status,
|
||||||
|
params.attributes,
|
||||||
|
params.priorityRules ?? []
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapProjectRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectById = async (id: string): Promise<Project | null> => {
|
||||||
|
const result = await query<ProjectRow>(
|
||||||
|
`
|
||||||
|
SELECT ${projectColumns}
|
||||||
|
FROM projects
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapProjectRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UpdateProjectParams {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: 'active' | 'archived';
|
||||||
|
attributes?: string[];
|
||||||
|
priorityRules?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProject = async (
|
||||||
|
id: string,
|
||||||
|
params: UpdateProjectParams
|
||||||
|
): Promise<Project | null> => {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (params.name !== undefined) {
|
||||||
|
values.push(params.name);
|
||||||
|
fields.push(`name = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.description !== undefined) {
|
||||||
|
values.push(params.description);
|
||||||
|
fields.push(`description = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.status !== undefined) {
|
||||||
|
values.push(params.status);
|
||||||
|
fields.push(`status = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.attributes !== undefined) {
|
||||||
|
values.push(params.attributes);
|
||||||
|
fields.push(`project_attributes = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.priorityRules !== undefined) {
|
||||||
|
values.push(params.priorityRules);
|
||||||
|
fields.push(`project_priority_rules = $${values.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return getProjectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
const idParamIndex = values.length;
|
||||||
|
|
||||||
|
const result = await query<ProjectRow>(
|
||||||
|
`
|
||||||
|
UPDATE projects
|
||||||
|
SET ${fields.join(', ')}, updated_at = NOW()
|
||||||
|
WHERE id = $${idParamIndex}
|
||||||
|
RETURNING ${projectColumns}
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapProjectRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProject = async (id: string): Promise<boolean> => {
|
||||||
|
const result = await query(
|
||||||
|
`
|
||||||
|
DELETE FROM projects
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { query } from './pool.js';
|
||||||
|
|
||||||
|
export interface UrlRow {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
note: string | null;
|
||||||
|
attributes: unknown;
|
||||||
|
has_price: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlRecord {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
note: string | null;
|
||||||
|
attributes: unknown;
|
||||||
|
hasPrice: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapUrlRow = (row: UrlRow): UrlRecord => ({
|
||||||
|
id: row.id,
|
||||||
|
url: row.url,
|
||||||
|
note: row.note,
|
||||||
|
attributes: row.attributes ?? {},
|
||||||
|
hasPrice: row.has_price,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findUrlById = async (id: string): Promise<UrlRecord | null> => {
|
||||||
|
const result = await query<UrlRow>(
|
||||||
|
`
|
||||||
|
SELECT id, url, note, attributes, has_price, created_at, updated_at
|
||||||
|
FROM urls
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapUrlRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUrlByValue = async (url: string): Promise<UrlRecord | null> => {
|
||||||
|
const result = await query<UrlRow>(
|
||||||
|
`
|
||||||
|
SELECT id, url, note, attributes, has_price, created_at, updated_at
|
||||||
|
FROM urls
|
||||||
|
WHERE url = $1
|
||||||
|
`,
|
||||||
|
[url]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapUrlRow(result.rows[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateUrlParams {
|
||||||
|
url: string;
|
||||||
|
note?: string | null;
|
||||||
|
attributes?: unknown;
|
||||||
|
hasPrice?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUrl = async (params: CreateUrlParams): Promise<UrlRecord> => {
|
||||||
|
const result = await query<UrlRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO urls (url, note, attributes, has_price)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (url) DO UPDATE
|
||||||
|
SET updated_at = NOW()
|
||||||
|
RETURNING id, url, note, attributes, has_price, created_at, updated_at
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
params.url,
|
||||||
|
params.note ?? null,
|
||||||
|
params.attributes ?? {},
|
||||||
|
params.hasPrice ?? false
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapUrlRow(result.rows[0]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ErrorRequestHandler } from 'express';
|
||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
res.status(error.status).json({
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
details: error.details ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled error', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: {
|
||||||
|
message: 'Internal Server Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,8 +1,50 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getHealth } from '../controllers/health-controller.js';
|
import {
|
||||||
|
createItem,
|
||||||
|
deleteItem,
|
||||||
|
getItem,
|
||||||
|
listItems,
|
||||||
|
updateItem
|
||||||
|
} from '../controllers/items-controller.js';
|
||||||
|
import {
|
||||||
|
createItemPrice,
|
||||||
|
deleteItemPrice,
|
||||||
|
getItemPrice,
|
||||||
|
listItemPrices,
|
||||||
|
updateItemPrice
|
||||||
|
} from '../controllers/item-prices-controller.js';
|
||||||
|
import {
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
getProject,
|
||||||
|
listProjects,
|
||||||
|
updateProject
|
||||||
|
} from '../controllers/projects-controller.js';
|
||||||
|
import { asyncHandler } from '../utils/async-handler.js';
|
||||||
|
|
||||||
const apiRouter = Router();
|
const apiRouter = Router();
|
||||||
|
|
||||||
apiRouter.get('/health', getHealth);
|
// Projects
|
||||||
|
apiRouter.get('/projects', asyncHandler(listProjects));
|
||||||
|
apiRouter.post('/projects', asyncHandler(createProject));
|
||||||
|
apiRouter.get('/projects/:projectId', asyncHandler(getProject));
|
||||||
|
apiRouter.patch('/projects/:projectId', asyncHandler(updateProject));
|
||||||
|
apiRouter.delete('/projects/:projectId', asyncHandler(deleteProject));
|
||||||
|
|
||||||
|
// Project items
|
||||||
|
apiRouter.get('/projects/:projectId/items', asyncHandler(listItems));
|
||||||
|
apiRouter.post('/projects/:projectId/items', asyncHandler(createItem));
|
||||||
|
|
||||||
|
// Items
|
||||||
|
apiRouter.get('/items/:itemId', asyncHandler(getItem));
|
||||||
|
apiRouter.patch('/items/:itemId', asyncHandler(updateItem));
|
||||||
|
apiRouter.delete('/items/:itemId', asyncHandler(deleteItem));
|
||||||
|
|
||||||
|
// Item prices
|
||||||
|
apiRouter.get('/items/:itemId/prices', asyncHandler(listItemPrices));
|
||||||
|
apiRouter.post('/items/:itemId/prices', asyncHandler(createItemPrice));
|
||||||
|
apiRouter.get('/prices/:priceId', asyncHandler(getItemPrice));
|
||||||
|
apiRouter.patch('/prices/:priceId', asyncHandler(updateItemPrice));
|
||||||
|
apiRouter.delete('/prices/:priceId', asyncHandler(deleteItemPrice));
|
||||||
|
|
||||||
export default apiRouter;
|
export default apiRouter;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { RequestHandler } from 'express';
|
||||||
|
|
||||||
|
export const asyncHandler = (handler: RequestHandler): RequestHandler => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(handler(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
export const normalizeUrl = (input: unknown): string => {
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
throw new HttpError(400, 'URL must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new HttpError(400, 'URL must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = decodeURI(trimmed);
|
||||||
|
return decoded.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpError(400, 'URL could not be decoded', { cause: (error as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export const parseUuid = (value: unknown, field: string): string => {
|
||||||
|
if (typeof value !== 'string' || !uuidRegex.test(value)) {
|
||||||
|
throw new HttpError(400, `${field} must be a valid UUID`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parsePaginationParams = (
|
||||||
|
query: Record<string, unknown>,
|
||||||
|
defaults: PaginationParams = { limit: 20, offset: 0 }
|
||||||
|
): PaginationParams => {
|
||||||
|
const limitRaw = query.limit;
|
||||||
|
const offsetRaw = query.offset;
|
||||||
|
|
||||||
|
const limit =
|
||||||
|
typeof limitRaw === 'string' && limitRaw.trim() !== ''
|
||||||
|
? Number(limitRaw)
|
||||||
|
: defaults.limit;
|
||||||
|
const offset =
|
||||||
|
typeof offsetRaw === 'string' && offsetRaw.trim() !== ''
|
||||||
|
? Number(offsetRaw)
|
||||||
|
: defaults.offset;
|
||||||
|
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0 || limit > 100) {
|
||||||
|
throw new HttpError(400, 'limit must be between 1 and 100');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(offset) || offset < 0) {
|
||||||
|
throw new HttpError(400, 'offset must be greater than or equal to 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { limit: Math.floor(limit), offset: Math.floor(offset) };
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { parseUuid } from './common.js';
|
||||||
|
|
||||||
|
const priceConditions = new Set(['new', 'used']);
|
||||||
|
const priceSources = new Set(['url', 'manual']);
|
||||||
|
|
||||||
|
const normalizeCurrency = (value: unknown): string => {
|
||||||
|
if (typeof value !== 'string' || value.trim().length !== 3) {
|
||||||
|
throw new HttpError(400, 'currency must be a 3-letter code');
|
||||||
|
}
|
||||||
|
return value.trim().toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseObservedAt = (value: unknown): string | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
throw new HttpError(400, 'observedAt cannot be null');
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, 'observedAt must be an ISO datetime string');
|
||||||
|
}
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.valueOf())) {
|
||||||
|
throw new HttpError(400, 'observedAt must be a valid ISO datetime string');
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ItemPriceCreateInput {
|
||||||
|
condition: 'new' | 'used';
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
sourceType: 'url' | 'manual';
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
sourceNote: string | null;
|
||||||
|
note: string | null;
|
||||||
|
observedAt: string | undefined;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemPriceUpdateInput = Partial<ItemPriceCreateInput>;
|
||||||
|
|
||||||
|
const parseAmount = (value: unknown): number => {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
|
||||||
|
throw new HttpError(400, 'amount must be a non-negative number');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSourceNote = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, 'sourceNote must be a string or null');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNote = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new HttpError(400, 'note must be a string or null');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseIsPrimary = (value: unknown): boolean => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
throw new HttpError(400, 'isPrimary must be a boolean');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSourceFields = (
|
||||||
|
sourceType: 'url' | 'manual',
|
||||||
|
sourceUrlIdValue: unknown,
|
||||||
|
sourceUrlValue: unknown
|
||||||
|
): { sourceUrlId: string | null; sourceUrl: string | null } => {
|
||||||
|
if (sourceType === 'manual') {
|
||||||
|
if (sourceUrlIdValue !== undefined && sourceUrlIdValue !== null) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
'sourceUrlId cannot be provided when sourceType is manual'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sourceUrlValue !== undefined && sourceUrlValue !== null) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
'sourceUrl cannot be provided when sourceType is manual'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { sourceUrlId: null, sourceUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceUrlId: string | null = null;
|
||||||
|
if (sourceUrlIdValue !== undefined && sourceUrlIdValue !== null) {
|
||||||
|
sourceUrlId = parseUuid(sourceUrlIdValue, 'sourceUrlId');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceUrl: string | null = null;
|
||||||
|
if (sourceUrlValue !== undefined && sourceUrlValue !== null) {
|
||||||
|
if (typeof sourceUrlValue !== 'string' || sourceUrlValue.trim().length === 0) {
|
||||||
|
throw new HttpError(400, 'sourceUrl must be a non-empty string');
|
||||||
|
}
|
||||||
|
sourceUrl = sourceUrlValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceUrlId && !sourceUrl) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
'sourceUrl or sourceUrlId must be provided when sourceType is url'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sourceUrlId, sourceUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseItemPriceCreatePayload = (
|
||||||
|
payload: unknown
|
||||||
|
): ItemPriceCreateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
condition,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
sourceType,
|
||||||
|
sourceUrlId,
|
||||||
|
sourceUrl,
|
||||||
|
sourceNote,
|
||||||
|
note,
|
||||||
|
observedAt,
|
||||||
|
isPrimary
|
||||||
|
} = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
|
||||||
|
throw new HttpError(400, 'condition must be one of new, used');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
|
||||||
|
throw new HttpError(400, 'sourceType must be one of url, manual');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = parseSourceFields(
|
||||||
|
sourceType as 'url' | 'manual',
|
||||||
|
sourceUrlId,
|
||||||
|
sourceUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
condition: condition as 'new' | 'used',
|
||||||
|
amount: parseAmount(amount),
|
||||||
|
currency: normalizeCurrency(currency),
|
||||||
|
sourceType: sourceType as 'url' | 'manual',
|
||||||
|
sourceUrlId: source.sourceUrlId,
|
||||||
|
sourceUrl: source.sourceUrl,
|
||||||
|
sourceNote: parseSourceNote(sourceNote),
|
||||||
|
note: parseNote(note),
|
||||||
|
observedAt: parseObservedAt(observedAt),
|
||||||
|
isPrimary: parseIsPrimary(isPrimary)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseItemPriceUpdatePayload = (
|
||||||
|
payload: unknown
|
||||||
|
): ItemPriceUpdateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ItemPriceUpdateInput = {};
|
||||||
|
const {
|
||||||
|
condition,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
sourceType,
|
||||||
|
sourceUrlId,
|
||||||
|
sourceUrl,
|
||||||
|
sourceNote,
|
||||||
|
note,
|
||||||
|
observedAt,
|
||||||
|
isPrimary
|
||||||
|
} = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (condition !== undefined) {
|
||||||
|
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
|
||||||
|
throw new HttpError(400, 'condition must be one of new, used');
|
||||||
|
}
|
||||||
|
result.condition = condition as 'new' | 'used';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount !== undefined) {
|
||||||
|
result.amount = parseAmount(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currency !== undefined) {
|
||||||
|
result.currency = normalizeCurrency(currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType !== undefined) {
|
||||||
|
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
|
||||||
|
throw new HttpError(400, 'sourceType must be one of url, manual');
|
||||||
|
}
|
||||||
|
result.sourceType = sourceType as 'url' | 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceUrlId !== undefined || sourceUrl !== undefined) {
|
||||||
|
const type = result.sourceType ?? (sourceType as 'url' | 'manual' | undefined);
|
||||||
|
if (!type) {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
'sourceType must be provided when changing sourceUrl or sourceUrlId'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsed = parseSourceFields(type, sourceUrlId, sourceUrl);
|
||||||
|
result.sourceUrlId = parsed.sourceUrlId;
|
||||||
|
result.sourceUrl = parsed.sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceNote !== undefined) {
|
||||||
|
result.sourceNote = parseSourceNote(sourceNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note !== undefined) {
|
||||||
|
result.note = parseNote(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observedAt !== undefined) {
|
||||||
|
result.observedAt = parseObservedAt(observedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrimary !== undefined) {
|
||||||
|
result.isPrimary = parseIsPrimary(isPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(result).length === 0) {
|
||||||
|
throw new HttpError(400, 'At least one field must be provided for update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parsePriceConditionFilter = (
|
||||||
|
value: unknown
|
||||||
|
): 'new' | 'used' | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !priceConditions.has(value)) {
|
||||||
|
throw new HttpError(400, 'condition filter must be one of new, used');
|
||||||
|
}
|
||||||
|
return value as 'new' | 'used';
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
import { parseUuid } from './common.js';
|
||||||
|
|
||||||
|
const itemStatuses = new Set(['active', 'rejected']);
|
||||||
|
|
||||||
|
const ensureAttributesObject = (value: unknown): Record<string, unknown> => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new HttpError(400, 'attributes must be an object');
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ItemCreateInput {
|
||||||
|
manufacturer: string | null;
|
||||||
|
model: string;
|
||||||
|
sourceUrlId: string | null;
|
||||||
|
sourceUrl: string | null;
|
||||||
|
status: 'active' | 'rejected';
|
||||||
|
note: string | null;
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemUpdateInput = Partial<ItemCreateInput>;
|
||||||
|
|
||||||
|
export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
manufacturer = null,
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
status = 'active',
|
||||||
|
note = null,
|
||||||
|
attributes,
|
||||||
|
sourceUrl,
|
||||||
|
sourceUrlId
|
||||||
|
} = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
const manufacturerValue =
|
||||||
|
manufacturer ?? (typeof brand === 'string' ? brand : null);
|
||||||
|
|
||||||
|
if (
|
||||||
|
manufacturerValue !== null &&
|
||||||
|
(typeof manufacturerValue !== 'string' || manufacturerValue.trim().length === 0)
|
||||||
|
) {
|
||||||
|
throw new HttpError(400, 'manufacturer must be a non-empty string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof model !== 'string' || model.trim().length === 0 || model.length > 150) {
|
||||||
|
throw new HttpError(400, 'model must be a non-empty string up to 150 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof status !== 'string' || !itemStatuses.has(status)) {
|
||||||
|
throw new HttpError(400, 'status must be one of active, rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note !== null && typeof note !== 'string') {
|
||||||
|
throw new HttpError(400, 'note must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedSourceUrlId: string | null = null;
|
||||||
|
if (sourceUrlId !== undefined && sourceUrlId !== null) {
|
||||||
|
normalizedSourceUrlId = parseUuid(sourceUrlId, 'sourceUrlId');
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedSourceUrl: string | null = null;
|
||||||
|
if (sourceUrl !== undefined && sourceUrl !== null) {
|
||||||
|
if (typeof sourceUrl !== 'string' || sourceUrl.trim().length === 0) {
|
||||||
|
throw new HttpError(400, 'sourceUrl must be a non-empty string when provided');
|
||||||
|
}
|
||||||
|
normalizedSourceUrl = sourceUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
manufacturer:
|
||||||
|
manufacturerValue === null ? null : (manufacturerValue as string).trim(),
|
||||||
|
model: model.trim(),
|
||||||
|
status: status as 'active' | 'rejected',
|
||||||
|
note: note === null ? null : (note as string),
|
||||||
|
attributes: ensureAttributesObject(attributes),
|
||||||
|
sourceUrlId: normalizedSourceUrlId,
|
||||||
|
sourceUrl: normalizedSourceUrl
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ItemUpdateInput = {};
|
||||||
|
const {
|
||||||
|
manufacturer,
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
status,
|
||||||
|
note,
|
||||||
|
attributes,
|
||||||
|
sourceUrl,
|
||||||
|
sourceUrlId
|
||||||
|
} = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (manufacturer !== undefined || brand !== undefined) {
|
||||||
|
const value = manufacturer ?? brand;
|
||||||
|
if (value === null) {
|
||||||
|
result.manufacturer = null;
|
||||||
|
} else if (typeof value === 'string' && value.trim().length > 0) {
|
||||||
|
result.manufacturer = value.trim();
|
||||||
|
} else {
|
||||||
|
throw new HttpError(
|
||||||
|
400,
|
||||||
|
'manufacturer/brand must be a non-empty string or null'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model !== undefined) {
|
||||||
|
if (typeof model !== 'string' || model.trim().length === 0 || model.length > 150) {
|
||||||
|
throw new HttpError(400, 'model must be a non-empty string up to 150 characters');
|
||||||
|
}
|
||||||
|
result.model = model.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
if (typeof status !== 'string' || !itemStatuses.has(status)) {
|
||||||
|
throw new HttpError(400, 'status must be one of active, rejected');
|
||||||
|
}
|
||||||
|
result.status = status as 'active' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note !== undefined) {
|
||||||
|
if (note === null) {
|
||||||
|
result.note = null;
|
||||||
|
} else if (typeof note === 'string') {
|
||||||
|
result.note = note;
|
||||||
|
} else {
|
||||||
|
throw new HttpError(400, 'note must be a string or null');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes !== undefined) {
|
||||||
|
result.attributes = ensureAttributesObject(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceUrlId !== undefined) {
|
||||||
|
if (sourceUrlId === null) {
|
||||||
|
result.sourceUrlId = null;
|
||||||
|
} else {
|
||||||
|
result.sourceUrlId = parseUuid(sourceUrlId, 'sourceUrlId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceUrl !== undefined) {
|
||||||
|
if (sourceUrl === null) {
|
||||||
|
result.sourceUrl = null;
|
||||||
|
} else if (typeof sourceUrl === 'string' && sourceUrl.trim().length > 0) {
|
||||||
|
result.sourceUrl = sourceUrl;
|
||||||
|
} else {
|
||||||
|
throw new HttpError(400, 'sourceUrl must be a non-empty string or null');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(result).length === 0) {
|
||||||
|
throw new HttpError(400, 'At least one field must be provided for update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseItemStatusFilter = (
|
||||||
|
value: unknown
|
||||||
|
): 'active' | 'rejected' | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !itemStatuses.has(value)) {
|
||||||
|
throw new HttpError(400, 'status filter must be one of active, rejected');
|
||||||
|
}
|
||||||
|
return value as 'active' | 'rejected';
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { HttpError } from '../errors/http-error.js';
|
||||||
|
|
||||||
|
const projectStatuses = new Set(['active', 'archived']);
|
||||||
|
|
||||||
|
const isStringArray = (value: unknown): value is string[] => {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every((item) => typeof item === 'string' && item.trim().length > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProjectCreateInput {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
projectAttributes: string[];
|
||||||
|
projectPriorityRules: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectUpdateInput = Partial<ProjectCreateInput>;
|
||||||
|
|
||||||
|
export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description = null,
|
||||||
|
status = 'active',
|
||||||
|
attributes,
|
||||||
|
priorityRules
|
||||||
|
} = payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 150) {
|
||||||
|
throw new HttpError(400, 'name must be a non-empty string up to 150 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== null && typeof description !== 'string') {
|
||||||
|
throw new HttpError(400, 'description must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof status !== 'string' || !projectStatuses.has(status)) {
|
||||||
|
throw new HttpError(400, 'status must be one of active, archived');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectAttributes = attributes ?? [];
|
||||||
|
if (!isStringArray(projectAttributes)) {
|
||||||
|
throw new HttpError(400, 'attributes must be an array of non-empty strings');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description === null ? null : description,
|
||||||
|
status: status as 'active' | 'archived',
|
||||||
|
projectAttributes,
|
||||||
|
projectPriorityRules: priorityRules ?? []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseProjectUpdatePayload = (
|
||||||
|
payload: unknown
|
||||||
|
): ProjectUpdateInput => {
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
throw new HttpError(400, 'Body must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ProjectUpdateInput = {};
|
||||||
|
const { name, description, status, attributes, priorityRules } =
|
||||||
|
payload as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 150) {
|
||||||
|
throw new HttpError(400, 'name must be a non-empty string up to 150 characters');
|
||||||
|
}
|
||||||
|
result.name = name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== undefined) {
|
||||||
|
if (description !== null && typeof description !== 'string') {
|
||||||
|
throw new HttpError(400, 'description must be a string or null');
|
||||||
|
}
|
||||||
|
result.description = description === null ? null : (description as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
if (typeof status !== 'string' || !projectStatuses.has(status)) {
|
||||||
|
throw new HttpError(400, 'status must be one of active, archived');
|
||||||
|
}
|
||||||
|
result.status = status as 'active' | 'archived';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes !== undefined) {
|
||||||
|
if (!isStringArray(attributes)) {
|
||||||
|
throw new HttpError(400, 'attributes must be an array of non-empty strings');
|
||||||
|
}
|
||||||
|
result.projectAttributes = attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityRules !== undefined) {
|
||||||
|
result.projectPriorityRules = priorityRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(result).length === 0) {
|
||||||
|
throw new HttpError(400, 'At least one field must be provided for update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseProjectStatusFilter = (value: unknown): 'active' | 'archived' | undefined => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || !projectStatuses.has(value)) {
|
||||||
|
throw new HttpError(400, 'status filter must be one of active, archived');
|
||||||
|
}
|
||||||
|
return value as 'active' | 'archived';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user