add shared

This commit is contained in:
Jurgis Sakalauskas
2025-10-14 09:14:01 +03:00
parent 3ac7e9183f
commit b20ab30ece
19 changed files with 134 additions and 211 deletions
+8 -29
View File
@@ -1,35 +1,14 @@
import type { PaginatedResponse } from '@shared/models/pagination';
import type {
ItemPrice,
PriceCondition,
PriceSourceType
} from '@shared/models/item-price';
import { apiFetch } from './http-client';
export type PriceCondition = 'new' | 'used';
export type PriceSourceType = 'url' | 'manual';
export type { PriceCondition, PriceSourceType };
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 type ItemPricesResponse = PaginatedResponse<ItemPrice>;
export interface FetchItemPricesParams {
limit?: number;
+3 -43
View File
@@ -1,48 +1,8 @@
import type { PaginatedResponse } from '@shared/models/pagination';
import type { Item, ItemStatus } from '@shared/models/item';
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;
newMinAmount: number | null;
newCount: number;
newCurrency: string | null;
newHasMixedCurrency: boolean;
usedMinAmount: number | null;
usedCount: number;
usedCurrency: string | null;
usedHasMixedCurrency: boolean;
}
interface ListResponseMeta {
limit: number;
offset: number;
count: number;
}
export interface ProjectItemsResponse {
data: Item[];
meta: ListResponseMeta;
}
export type ProjectItemsResponse = PaginatedResponse<Item>;
export interface FetchProjectItemsParams {
limit?: number;
+3 -23
View File
@@ -1,28 +1,8 @@
import type { PaginatedResponse } from '@shared/models/pagination';
import type { Project, ProjectStatus } from '@shared/models/project';
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 type ProjectsListResponse = PaginatedResponse<Project>;
export interface FetchProjectsParams {
limit?: number;
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import type { Item } from '../../api/items';
import type { Item } from '@shared/models/item';
import { ItemPricesPanel } from './item-prices-panel';
interface ItemPricesModalProps {
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Item } from '@shared/models/item';
import {
useCreateItemMutation,
useUpdateItemMutation,
@@ -11,7 +12,6 @@ import {
importItemFromUrl,
type CreateItemPayload,
type ImportedItemData,
type Item,
type UpdateItemPayload
} from '../../api/items';
import { ItemFormModal } from '../../components/item-form-modal';
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import type { ProjectStatus } from '../../api/projects';
import type { ProjectStatus } from '@shared/models/project';
interface ProjectHeaderProps {
name: string;
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { ExternalLink, Pencil } from 'lucide-react';
import type { Item } from '../../api/items';
import type { Item } from '@shared/models/item';
interface ProjectItemsSectionProps {
items: Item[];
+1 -1
View File
@@ -4,7 +4,6 @@ import {
fetchProjects,
updateProject,
type CreateProjectPayload,
type Project,
type ProjectsListResponse,
type UpdateProjectPayload
} from '../api/projects';
@@ -16,6 +15,7 @@ import {
type ProjectItemsResponse,
type UpdateItemPayload
} from '../api/items';
import type { Project } from '@shared/models/project';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const DEFAULT_LIMIT = 50;
+4 -1
View File
@@ -7,7 +7,10 @@
"jsx": "react-jsx",
"types": ["vite/client"],
"allowSyntheticDefaultImports": true,
"baseUrl": "./src"
"baseUrl": "./src",
"paths": {
"@shared/*": ["../../shared/*.d.ts"]
}
},
"include": [
"src"
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
port: clientPort,
proxy: {
'/api': proxyTarget
},
fs: {
allow: [path.resolve(__dirname, '..')]
}
},
resolve: {
alias: {
'@shared': path.resolve(__dirname, '../shared')
}
}
};
+10
View File
@@ -27,6 +27,7 @@
"license": "MIT",
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"lucide-react": "^0.545.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.4"
@@ -3926,6 +3927,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.545.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
+9 -23
View File
@@ -1,3 +1,4 @@
import type { ItemPrice, PriceCondition, PriceSourceType } from '@shared/models/item-price';
import { pool, query } from './pool.js';
const priceColumns = `
@@ -20,10 +21,10 @@ const priceColumns = `
export interface ItemPriceRow {
id: string;
item_id: string;
condition: 'new' | 'used';
condition: PriceCondition;
amount: string;
currency: string;
source_type: 'url' | 'manual';
source_type: PriceSourceType;
source_url_id: string | null;
source_note: string | null;
note: string | null;
@@ -34,22 +35,7 @@ export interface ItemPriceRow {
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;
}
export type ItemPriceRecord = ItemPrice;
const mapItemPriceRow = (row: ItemPriceRow): ItemPriceRecord => ({
id: row.id,
@@ -72,7 +58,7 @@ export interface ListItemPricesOptions {
itemId: string;
limit: number;
offset: number;
condition?: 'new' | 'used';
condition?: PriceCondition;
}
export const listItemPrices = async (
@@ -109,10 +95,10 @@ export const listItemPrices = async (
export interface CreateItemPriceParams {
itemId: string;
condition: 'new' | 'used';
condition: PriceCondition;
amount: number;
currency: string;
sourceType: 'url' | 'manual';
sourceType: PriceSourceType;
sourceUrlId: string | null;
sourceNote: string | null;
note: string | null;
@@ -213,10 +199,10 @@ export const getItemPriceById = async (id: string): Promise<ItemPriceRecord | nu
};
export interface UpdateItemPriceParams {
condition?: 'new' | 'used';
condition?: PriceCondition;
amount?: number;
currency?: string;
sourceType?: 'url' | 'manual';
sourceType?: PriceSourceType;
sourceUrlId?: string | null;
sourceNote?: string | null;
note?: string | null;
+6 -34
View File
@@ -1,3 +1,4 @@
import type { Item, ItemPriceSummary, ItemStatus } from '@shared/models/item';
import { query } from './pool.js';
const itemColumns = `
@@ -33,7 +34,7 @@ export interface ItemRow {
manufacturer: string | null;
model: string;
source_url_id: string | null;
status: 'active' | 'rejected';
status: ItemStatus;
note: string | null;
attributes: Record<string, unknown>;
created_at: Date;
@@ -54,36 +55,7 @@ export interface ItemRow {
price_used_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;
newMinAmount: number | null;
newCount: number;
newCurrency: string | null;
newHasMixedCurrency: boolean;
usedMinAmount: number | null;
usedCount: number;
usedCurrency: string | null;
usedHasMixedCurrency: boolean;
}
export type ItemRecord = Item;
const mapItemRow = (row: ItemRow): ItemRecord => {
const priceCount = row.price_count ?? 0;
@@ -126,7 +98,7 @@ const mapItemRow = (row: ItemRow): ItemRecord => {
export interface ListItemsOptions {
projectId: string;
status?: 'active' | 'rejected';
status?: ItemStatus;
limit: number;
offset: number;
}
@@ -184,7 +156,7 @@ export interface CreateItemParams {
manufacturer: string | null;
model: string;
sourceUrlId: string | null;
status: 'active' | 'rejected';
status: ItemStatus;
note: string | null;
attributes: Record<string, unknown>;
}
@@ -260,7 +232,7 @@ export interface UpdateItemParams {
manufacturer?: string | null;
model?: string;
sourceUrlId?: string | null;
status?: 'active' | 'rejected';
status?: ItemStatus;
note?: string | null;
attributes?: Record<string, unknown>;
}
+5 -15
View File
@@ -1,3 +1,4 @@
import type { Project, ProjectStatus } from '@shared/models/project';
import { query } from './pool.js';
const projectColumns = `
@@ -15,24 +16,13 @@ export interface ProjectRow {
id: string;
name: string;
description: string | null;
status: 'active' | 'archived';
status: ProjectStatus;
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,
@@ -47,7 +37,7 @@ const mapProjectRow = (row: ProjectRow): Project => ({
export interface ListProjectsOptions {
limit: number;
offset: number;
status?: 'active' | 'archived';
status?: ProjectStatus;
}
export const listProjects = async (
@@ -86,7 +76,7 @@ export const listProjects = async (
export interface CreateProjectParams {
name: string;
description: string | null;
status: 'active' | 'archived';
status: ProjectStatus;
attributes: string[];
priorityRules: unknown;
}
@@ -136,7 +126,7 @@ export const getProjectById = async (id: string): Promise<Project | null> => {
export interface UpdateProjectParams {
name?: string;
description?: string | null;
status?: 'active' | 'archived';
status?: ProjectStatus;
attributes?: string[];
priorityRules?: unknown;
}
+27 -18
View File
@@ -1,8 +1,17 @@
import type { PriceCondition, PriceSourceType } from '@shared/models/item-price';
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 priceConditions = new Set<PriceCondition>(['new', 'used']);
const priceSources = new Set<PriceSourceType>(['url', 'manual']);
const isPriceCondition = (value: unknown): value is PriceCondition => {
return typeof value === 'string' && priceConditions.has(value as PriceCondition);
};
const isPriceSourceType = (value: unknown): value is PriceSourceType => {
return typeof value === 'string' && priceSources.has(value as PriceSourceType);
};
const normalizeCurrency = (value: unknown): string => {
if (typeof value !== 'string' || value.trim().length !== 3) {
@@ -29,10 +38,10 @@ const parseObservedAt = (value: unknown): string | undefined => {
};
export interface ItemPriceCreateInput {
condition: 'new' | 'used';
condition: PriceCondition;
amount: number;
currency: string;
sourceType: 'url' | 'manual';
sourceType: PriceSourceType;
sourceUrlId: string | null;
sourceUrl: string | null;
sourceNote: string | null;
@@ -81,7 +90,7 @@ const parseIsPrimary = (value: unknown): boolean => {
};
const parseSourceFields = (
sourceType: 'url' | 'manual',
sourceType: PriceSourceType,
sourceUrlIdValue: unknown,
sourceUrlValue: unknown
): { sourceUrlId: string | null; sourceUrl: string | null } => {
@@ -144,25 +153,25 @@ export const parseItemPriceCreatePayload = (
isPrimary
} = payload as Record<string, unknown>;
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
if (!isPriceCondition(condition)) {
throw new HttpError(400, 'condition must be one of new, used');
}
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
if (!isPriceSourceType(sourceType)) {
throw new HttpError(400, 'sourceType must be one of url, manual');
}
const source = parseSourceFields(
sourceType as 'url' | 'manual',
sourceType,
sourceUrlId,
sourceUrl
);
return {
condition: condition as 'new' | 'used',
condition,
amount: parseAmount(amount),
currency: normalizeCurrency(currency),
sourceType: sourceType as 'url' | 'manual',
sourceType,
sourceUrlId: source.sourceUrlId,
sourceUrl: source.sourceUrl,
sourceNote: parseSourceNote(sourceNote),
@@ -194,10 +203,10 @@ export const parseItemPriceUpdatePayload = (
} = payload as Record<string, unknown>;
if (condition !== undefined) {
if (typeof condition !== 'string' || !priceConditions.has(condition)) {
if (!isPriceCondition(condition)) {
throw new HttpError(400, 'condition must be one of new, used');
}
result.condition = condition as 'new' | 'used';
result.condition = condition;
}
if (amount !== undefined) {
@@ -209,14 +218,14 @@ export const parseItemPriceUpdatePayload = (
}
if (sourceType !== undefined) {
if (typeof sourceType !== 'string' || !priceSources.has(sourceType)) {
if (!isPriceSourceType(sourceType)) {
throw new HttpError(400, 'sourceType must be one of url, manual');
}
result.sourceType = sourceType as 'url' | 'manual';
result.sourceType = sourceType;
}
if (sourceUrlId !== undefined || sourceUrl !== undefined) {
const type = result.sourceType ?? (sourceType as 'url' | 'manual' | undefined);
const type = result.sourceType ?? (isPriceSourceType(sourceType) ? sourceType : undefined);
if (!type) {
throw new HttpError(
400,
@@ -253,12 +262,12 @@ export const parseItemPriceUpdatePayload = (
export const parsePriceConditionFilter = (
value: unknown
): 'new' | 'used' | undefined => {
): PriceCondition | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !priceConditions.has(value)) {
if (!isPriceCondition(value)) {
throw new HttpError(400, 'condition filter must be one of new, used');
}
return value as 'new' | 'used';
return value;
};
+14 -9
View File
@@ -1,7 +1,12 @@
import type { ItemStatus } from '@shared/models/item';
import { HttpError } from '../errors/http-error.js';
import { parseUuid } from './common.js';
const itemStatuses = new Set(['active', 'rejected']);
const itemStatuses = new Set<ItemStatus>(['active', 'rejected']);
const isItemStatus = (value: unknown): value is ItemStatus => {
return typeof value === 'string' && itemStatuses.has(value as ItemStatus);
};
const ensureAttributesObject = (value: unknown): Record<string, unknown> => {
if (value === undefined) {
@@ -18,7 +23,7 @@ export interface ItemCreateInput {
model: string;
sourceUrlId: string | null;
sourceUrl: string | null;
status: 'active' | 'rejected';
status: ItemStatus;
note: string | null;
attributes: Record<string, unknown>;
}
@@ -55,7 +60,7 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
throw new HttpError(400, 'model must be a non-empty string up to 150 characters');
}
if (typeof status !== 'string' || !itemStatuses.has(status)) {
if (!isItemStatus(status)) {
throw new HttpError(400, 'status must be one of active, rejected');
}
@@ -80,7 +85,7 @@ export const parseItemCreatePayload = (payload: unknown): ItemCreateInput => {
manufacturer:
manufacturerValue === null ? null : (manufacturerValue as string).trim(),
model: model.trim(),
status: status as 'active' | 'rejected',
status: status as ItemStatus,
note: note === null ? null : (note as string),
attributes: ensureAttributesObject(attributes),
sourceUrlId: normalizedSourceUrlId,
@@ -127,10 +132,10 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
}
if (status !== undefined) {
if (typeof status !== 'string' || !itemStatuses.has(status)) {
if (!isItemStatus(status)) {
throw new HttpError(400, 'status must be one of active, rejected');
}
result.status = status as 'active' | 'rejected';
result.status = status;
}
if (note !== undefined) {
@@ -174,14 +179,14 @@ export const parseItemUpdatePayload = (payload: unknown): ItemUpdateInput => {
export const parseItemStatusFilter = (
value: unknown
): 'active' | 'rejected' | undefined => {
): ItemStatus | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !itemStatuses.has(value)) {
if (!isItemStatus(value)) {
throw new HttpError(400, 'status filter must be one of active, rejected');
}
return value as 'active' | 'rejected';
return value;
};
export interface ItemImportPayload {
+14 -9
View File
@@ -1,6 +1,11 @@
import type { ProjectStatus } from '@shared/models/project';
import { HttpError } from '../errors/http-error.js';
const projectStatuses = new Set(['active', 'archived']);
const projectStatuses = new Set<ProjectStatus>(['active', 'archived']);
const isProjectStatus = (value: unknown): value is ProjectStatus => {
return typeof value === 'string' && projectStatuses.has(value as ProjectStatus);
};
const isStringArray = (value: unknown): value is string[] => {
return (
@@ -12,7 +17,7 @@ const isStringArray = (value: unknown): value is string[] => {
export interface ProjectCreateInput {
name: string;
description: string | null;
status: 'active' | 'archived';
status: ProjectStatus;
projectAttributes: string[];
projectPriorityRules: unknown;
}
@@ -40,7 +45,7 @@ export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput
throw new HttpError(400, 'description must be a string or null');
}
if (typeof status !== 'string' || !projectStatuses.has(status)) {
if (!isProjectStatus(status)) {
throw new HttpError(400, 'status must be one of active, archived');
}
@@ -52,7 +57,7 @@ export const parseProjectCreatePayload = (payload: unknown): ProjectCreateInput
return {
name: name.trim(),
description: description === null ? null : description,
status: status as 'active' | 'archived',
status: status as ProjectStatus,
projectAttributes,
projectPriorityRules: priorityRules ?? []
};
@@ -84,10 +89,10 @@ export const parseProjectUpdatePayload = (
}
if (status !== undefined) {
if (typeof status !== 'string' || !projectStatuses.has(status)) {
if (!isProjectStatus(status)) {
throw new HttpError(400, 'status must be one of active, archived');
}
result.status = status as 'active' | 'archived';
result.status = status;
}
if (attributes !== undefined) {
@@ -108,12 +113,12 @@ export const parseProjectUpdatePayload = (
return result;
};
export const parseProjectStatusFilter = (value: unknown): 'active' | 'archived' | undefined => {
export const parseProjectStatusFilter = (value: unknown): ProjectStatus | undefined => {
if (value === undefined) {
return undefined;
}
if (typeof value !== 'string' || !projectStatuses.has(value)) {
if (!isProjectStatus(value)) {
throw new HttpError(400, 'status filter must be one of active, archived');
}
return value as 'active' | 'archived';
return value;
};
+11 -1
View File
@@ -2,7 +2,17 @@
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"baseUrl": ".",
"rootDirs": [
"src",
"../shared"
],
"paths": {
"@shared/*": [
"../shared/*.d.ts"
]
}
},
"include": [
"src"
+7 -1
View File
@@ -7,6 +7,12 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@shared/*": [
"shared/*.d.ts"
]
}
}
}