improve endpoints

This commit is contained in:
yaschalew
2026-05-21 01:11:51 +03:00
parent 3150da2671
commit fea308a6af
11 changed files with 341 additions and 1981 deletions

View File

@@ -1 +0,0 @@
VITE_API_URL=http://localhost:3001

View File

@@ -0,0 +1,3 @@
export const FILE_SETTINGS = {
CUSTOMER_REGISTRATION: "customer_registration"
}

View File

@@ -0,0 +1,8 @@
export const QUERY_KEYS = {
USERS: "users",
CUSTOMER: "Customers",
FILES: {
FILE_UPLOAD_SETTINGS: "file-upload-settings",
BY_CODE: "by-code"
}
}

View File

@@ -36,6 +36,7 @@ export const URL_CONSTANTS = {
BASE: "/files", BASE: "/files",
UPLOAD: "/files/upload", UPLOAD: "/files/upload",
FILE_UPLOAD_SETTINGS: "/files/upload", FILE_UPLOAD_SETTINGS: "/files/upload",
FILE_UPLOAD_SETTINGS_BY_CODE: "/api/file-upload-settings/by-code",
DOWNLOAD: (id: string | number) => `/files/${id}/download`, DOWNLOAD: (id: string | number) => `/files/${id}/download`,
DELETE: (id: string | number) => `/files/${id}`, DELETE: (id: string | number) => `/files/${id}`,
BY_ID: (id: string | number) => `/files/${id}`, BY_ID: (id: string | number) => `/files/${id}`,

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import { IFileUploadSetting } from "@edr/types/freight";
import { useState, type ReactNode } from "react";
import { import {
Dialog, Dialog,
@@ -11,6 +12,7 @@ import {
Label, Label,
Button, Button,
Textarea, Textarea,
SmartFileInput,
} from "@edr/ui-common"; } from "@edr/ui-common";
import { import {
@@ -22,6 +24,9 @@ import {
MapPin, MapPin,
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
import { useQuery } from "@tanstack/react-query";
export interface CustomerFormData { export interface CustomerFormData {
companyName?: string; companyName?: string;
@@ -46,7 +51,78 @@ export default function NewCustomerPage({
mode = "create", mode = "create",
customer, customer,
children, children,
}: NewCustomerPageProps = {}) { }: NewCustomerPageProps = {}) { //getFileUploadSettingByCode
const [files, setFiles] = useState<
Record<string, File | File[] | null>
>({});
const { data: customerRegistrationFiles, isLoading, isError, error } = useQuery(
getFileUploadSettingByCode.queryOptions({
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
})
);
console.log("customerRegistrationFiles", customerRegistrationFiles)
// const shipmentFileUploadSetting: IFileUploadSetting = {
// id: "setting_1",
// code: "shipment_documents",
// label: "Shipment Documents",
// description: "Upload all required shipment-related documents",
// entity: "shipment", // depends on your FileUploadEntity enum/type
// fields: [
// {
// id: "field_1",
// fileKey: "invoice",
// fileLabel: "Invoice",
// order: 1,
// isRequired: true,
// isMultiple: false,
// maxFiles: 1,
// maxSizeMb: 5,
// allowedExtensions: ["pdf", "jpg", "png"],
// helpText: "Upload commercial invoice",
// settingId: "",
// createdAt: "",
// updatedAt: ""
// },
// {
// id: "field_2",
// fileKey: "packing_list",
// fileLabel: "Packing List",
// order: 2,
// isRequired: false,
// isMultiple: true,
// maxFiles: 3,
// maxSizeMb: 10,
// allowedExtensions: ["pdf", "xlsx"],
// helpText: "Optional packing list documents",
// settingId: "",
// createdAt: "",
// updatedAt: ""
// },
// {
// id: "field_3",
// fileKey: "cargo_images",
// fileLabel: "Cargo Images",
// order: 3,
// isRequired: false,
// isMultiple: true,
// maxFiles: 5,
// maxSizeMb: 2,
// allowedExtensions: ["jpg", "jpeg", "png"],
// helpText: "Photos of cargo condition",
// settingId: "",
// createdAt: "",
// updatedAt: ""
// },
// ],
// createdAt: "",
// updatedAt: ""
// };
const isEdit = mode === "edit"; const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer"; const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit const description = isEdit
@@ -208,7 +284,16 @@ export default function NewCustomerPage({
/> />
</div> </div>
</div> </div>
<div>
{
customerRegistrationFiles &&
<SmartFileInput
file={customerRegistrationFiles}
value={files}
onChange={setFiles}
/>
}
</div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button> <Button variant="outline">Cancel</Button>

View File

@@ -1,4 +1,5 @@
import { api } from "./crud"; import { endpoint, unwrap } from "@/utils/endpoint";
import { client } from "@/utils/api";
import type { import type {
CreateFileUploadFieldDto, CreateFileUploadFieldDto,
@@ -8,6 +9,9 @@ import type {
UpdateFileUploadFieldDto, UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto, UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings"; } from "@/types/fileUploadSettings";
import { URL_CONSTANTS } from "@/constants/URLS";
import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
import { ApiResponse } from "@/types/apiResponse";
const BASE = "/api/file-upload-settings"; const BASE = "/api/file-upload-settings";
@@ -18,25 +22,24 @@ const BASE = "/api/file-upload-settings";
*/ */
type Envelope<T> = { data: T } | T; type Envelope<T> = { data: T } | T;
function unwrap<T>(payload: Envelope<T>): T { // function unwrap<T>(payload: Envelope<T>): T {
if ( // if (
payload && // payload &&
typeof payload === "object" && // typeof payload === "object" &&
"data" in (payload as object) // "data" in (payload as object)
) { // ) {
return (payload as { data: T }).data; // return (payload as { data: T }).data;
} // }
return payload as T; // return payload as T;
} // }
export const fileUploadSettingsService = { export const fileUploadSettingsService = {
// GET /file-upload-settings // GET /file-upload-settings
list: async (): Promise<FileUploadSetting[]> => { list: async (): Promise<FileUploadSetting[]> => {
const response = const response =
await api.get<Envelope<FileUploadSetting[]>>(BASE); await client.get<ApiResponse<FileUploadSetting[]>>(BASE);
return unwrap(response.data);
return unwrap(response);
}, },
// GET /file-upload-settings/:id // GET /file-upload-settings/:id
@@ -44,11 +47,11 @@ export const fileUploadSettingsService = {
id: string id: string
): Promise<FileUploadSetting> => { ): Promise<FileUploadSetting> => {
const response = const response =
await api.get<Envelope<FileUploadSetting>>( await client.get<ApiResponse<FileUploadSetting>>(
`${BASE}/${id}` `${BASE}/${id}`
); );
return unwrap(response); return unwrap(response.data);
}, },
// GET /file-upload-settings/by-code/:code // GET /file-upload-settings/by-code/:code
@@ -56,11 +59,11 @@ export const fileUploadSettingsService = {
code: string code: string
): Promise<FileUploadSetting> => { ): Promise<FileUploadSetting> => {
const response = const response =
await api.get<Envelope<FileUploadSetting>>( await client.get<ApiResponse<FileUploadSetting>>(
`${BASE}/by-code/${encodeURIComponent(code)}` `${BASE}/by-code/${encodeURIComponent(code)}`
); );
return unwrap(response); return unwrap(response.data);
}, },
// POST /file-upload-settings // POST /file-upload-settings
@@ -68,12 +71,12 @@ export const fileUploadSettingsService = {
payload: CreateFileUploadSettingDto payload: CreateFileUploadSettingDto
): Promise<FileUploadSetting> => { ): Promise<FileUploadSetting> => {
const response = const response =
await api.post<Envelope<FileUploadSetting>>( await client.post<ApiResponse<FileUploadSetting>>(
BASE, BASE,
payload payload
); );
return unwrap(response); return unwrap(response.data);
}, },
// PATCH /file-upload-settings/:id // PATCH /file-upload-settings/:id
@@ -82,17 +85,17 @@ export const fileUploadSettingsService = {
payload: UpdateFileUploadSettingDto payload: UpdateFileUploadSettingDto
): Promise<FileUploadSetting> => { ): Promise<FileUploadSetting> => {
const response = const response =
await api.patch<Envelope<FileUploadSetting>>( await client.patch<ApiResponse<FileUploadSetting>>(
`${BASE}/${id}`, `${BASE}/${id}`,
payload payload
); );
return unwrap(response); return unwrap(response.data);
}, },
// DELETE /file-upload-settings/:id // DELETE /file-upload-settings/:id
remove: async (id: string): Promise<void> => { remove: async (id: string): Promise<void> => {
await api.delete(`${BASE}/${id}`); await client.delete(`${BASE}/${id}`);
}, },
// PUT /file-upload-settings/:id/fields // PUT /file-upload-settings/:id/fields
@@ -101,12 +104,11 @@ export const fileUploadSettingsService = {
fields: CreateFileUploadFieldDto[] fields: CreateFileUploadFieldDto[]
): Promise<FileUploadField[]> => { ): Promise<FileUploadField[]> => {
const response = const response =
await api.put<Envelope<FileUploadField[]>>( await client.put<ApiResponse<FileUploadField[]>>(
`${BASE}/${id}/fields`, `${BASE}/${id}/fields`,
fields fields
); );
return unwrap(response.data);
return unwrap(response);
}, },
// POST /file-upload-settings/:id/fields // POST /file-upload-settings/:id/fields
@@ -115,12 +117,12 @@ export const fileUploadSettingsService = {
payload: CreateFileUploadFieldDto payload: CreateFileUploadFieldDto
): Promise<FileUploadField> => { ): Promise<FileUploadField> => {
const response = const response =
await api.post<Envelope<FileUploadField>>( await client.post<ApiResponse<FileUploadField>>(
`${BASE}/${id}/fields`, `${BASE}/${id}/fields`,
payload payload
); );
return unwrap(response); return unwrap(response.data);
}, },
// PATCH /file-upload-settings/fields/:fieldId // PATCH /file-upload-settings/fields/:fieldId
@@ -129,20 +131,29 @@ export const fileUploadSettingsService = {
payload: UpdateFileUploadFieldDto payload: UpdateFileUploadFieldDto
): Promise<FileUploadField> => { ): Promise<FileUploadField> => {
const response = const response =
await api.patch<Envelope<FileUploadField>>( await client.patch<ApiResponse<FileUploadField>>(
`${BASE}/fields/${fieldId}`, `${BASE}/fields/${fieldId}`,
payload payload
); );
return unwrap(response.data);
return unwrap(response);
}, },
// DELETE /file-upload-settings/fields/:fieldId // DELETE /file-upload-settings/fields/:fieldId
removeField: async ( removeField: async (
fieldId: string fieldId: string
): Promise<void> => { ): Promise<void> => {
await api.delete( await client.delete(
`${BASE}/fields/${fieldId}` `${BASE}/fields/${fieldId}`
); );
}, },
}; };
export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS,
QUERY_KEYS.FILES.BY_CODE,
(code) =>
client
.get<ApiResponse<FileUploadSetting>>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
.then(res => res.data.data)
);

View File

@@ -0,0 +1,5 @@
export type ApiResponse<T> = {
success: boolean;
data: T;
timestamp: string;
};

View File

@@ -1,11 +1,20 @@
import { QueryObserverOptions, UseQueryOptions } from "@tanstack/react-query"; import {
UseQueryOptions,
QueryObserverOptions,
} from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
// ---------------------------------------------------------------------------
// Axios client
// ---------------------------------------------------------------------------
export const client = axios.create({ export const client = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
}); });
// Attach auth token to every request
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
// TODO: replace with secure storage (cookie/localStorage/auth provider)
const token = document.cookie const token = document.cookie
.split("; ") .split("; ")
.find((row) => row.startsWith("auth-token=")) .find((row) => row.startsWith("auth-token="))
@@ -13,9 +22,11 @@ client.interceptors.request.use((config) => {
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
}); });
// Handle auth errors globally
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
@@ -23,90 +34,5 @@ client.interceptors.response.use(
window.location.href = "/auth"; window.location.href = "/auth";
} }
return Promise.reject(error); return Promise.reject(error);
},
);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type QueryConfig<T> = Omit<
UseQueryOptions<T, Error, T, readonly unknown[]>,
"queryKey" | "queryFn"
>;
export interface EndpointWithInput<TInput, TResponse> {
call(input: TInput): Promise<TResponse>;
queryKey(): readonly unknown[];
queryKey(input: TInput): readonly unknown[];
queryOptions(
config: { input: TInput } & QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export interface EndpointWithoutInput<TResponse> {
call(): Promise<TResponse>;
queryKey(): readonly unknown[];
queryOptions(
config?: QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export type Endpoint<TInput, TResponse> = TInput extends void
? EndpointWithoutInput<TResponse>
: EndpointWithInput<TInput, TResponse>;
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
export function endpoint<TInput, TResponse>(
service: string,
action: string,
execute: (input: TInput) => Promise<TResponse>,
): Endpoint<TInput, TResponse> {
const call = (input: TInput) => execute(input);
const queryKey = (input?: TInput): readonly unknown[] => {
if (input === undefined) return [service, action] as const;
return [service, action, input] as const;
};
const queryOptions = (
config?: Record<string, unknown>,
): QueryObserverOptions<
TResponse,
Error,
TResponse,
TResponse,
readonly unknown[]
> => {
const input = config?.input as TInput | undefined;
const { input: _, ...rest } = config ?? {};
const key: readonly unknown[] =
input !== undefined ? [service, action, input] : [service, action];
return {
...rest,
queryKey: key,
queryFn: () => execute(input as TInput),
} as ReturnType<typeof queryOptions>;
};
return { call, queryKey, queryOptions } as Endpoint<TInput, TResponse>;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Some API endpoints return `{ data: T }`, others return `T` directly. */
export function unwrap<T>(response: { data: T } | T): T {
if (
response &&
typeof response === "object" &&
"data" in (response as object)
) {
return (response as { data: T }).data;
} }
return response as T; );
}

View File

@@ -0,0 +1,94 @@
import {
UseQueryOptions,
} from "@tanstack/react-query";
// ---------------------------------------------------------------------------
// React Query shared types
// ---------------------------------------------------------------------------
export type QueryConfig<T> = Omit<
UseQueryOptions<T, Error, T, readonly unknown[]>,
"queryKey" | "queryFn"
>;
// ---------------------------------------------------------------------------
// Endpoint interfaces
// ---------------------------------------------------------------------------
export interface EndpointWithInput<TInput, TResponse> {
call(input: TInput): Promise<TResponse>;
queryKey(input: TInput): readonly unknown[];
queryOptions(
config: { input: TInput } & QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export interface EndpointWithoutInput<TResponse> {
call(): Promise<TResponse>;
queryKey(): readonly unknown[];
queryOptions(
config?: QueryConfig<TResponse>,
): UseQueryOptions<TResponse, Error, TResponse, readonly unknown[]>;
}
export type Endpoint<TInput, TResponse> = TInput extends void
? EndpointWithoutInput<TResponse>
: EndpointWithInput<TInput, TResponse>;
// ---------------------------------------------------------------------------
// Endpoint builder
// ---------------------------------------------------------------------------
export function endpoint<TInput, TResponse>(
service: string,
action: string,
execute: (input: TInput) => Promise<TResponse>,
) {
const buildKey = (input?: TInput): readonly unknown[] =>
input === undefined
? [service, action]
: [service, action, input];
const call = (input: TInput) => execute(input);
const queryKey = (input?: TInput) => buildKey(input);
const queryOptions = (
config?: { input?: TInput } & QueryConfig<TResponse>,
): UseQueryOptions<
TResponse,
Error,
TResponse,
readonly unknown[]
> => {
const { input, ...rest } = config ?? {};
return {
...rest,
queryKey: buildKey(input),
queryFn: () => execute(input as TInput),
};
};
return {
call,
queryKey,
queryOptions,
};
}
// ---------------------------------------------------------------------------
// Helper utilities
// ---------------------------------------------------------------------------
export function unwrap<T>(response: { data: T } | T): T {
if (
response &&
typeof response === "object" &&
"data" in (response as object)
) {
return (response as { data: T }).data;
}
return response as T;
}

View File

@@ -1,5 +1,5 @@
export { default as Table } from "./components/Table"; export { Table } from "./components/table";
export type { TableProps, TableColumn } from "./components/Table"; export type { TableProps, TableColumn } from "./components/Table/Table";
export { FormField } from "./components/Form"; export { FormField } from "./components/Form";
export type { FormFieldProps } from "./components/Form"; export type { FormFieldProps } from "./components/Form";
@@ -10,8 +10,8 @@ export type { SmartFileInputProps } from "./components/SmartFileInput";
export { default as Modal } from "./components/Modal"; export { default as Modal } from "./components/Modal";
export type { ModalProps } from "./components/Modal"; export type { ModalProps } from "./components/Modal";
export { default as Badge } from "./components/Badge"; export { Badge } from "./components/badge";
export type { BadgeProps, BadgeTone } from "./components/Badge"; // export type { BadgeProps } from "./components/badge";
export { Sidebar, DashboardLayout } from "./components/Layout"; export { Sidebar, DashboardLayout } from "./components/Layout";

1934
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff