first action update user pages .
2-update driver pages [fixed image display and notyfi] 3-update car pages [fixed image] 4-build tripe pages crud 5-build some role pages 6-build audit page 7-update ui compounants 8-update saidebar and topbar 9-cheange view to be ar view 10-cheange stractcher to be app,src 11-add validation layer by yup 12-add api image-proxy to broke image corse bloken
This commit is contained in:
72
src/services/api.ts
Normal file
72
src/services/api.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { requestJson } from "@/src/lib/api";
|
||||
|
||||
function resolveBase() {
|
||||
if (typeof window === "undefined") {
|
||||
return process.env.INTERNAL_API_BASE_URL ?? "";
|
||||
}
|
||||
return "/api/proxy";
|
||||
}
|
||||
|
||||
// ── Error type ─────────────────────────────────────────
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core request helper ────────────────────────────────
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
token?: string | null,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
|
||||
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const url = `${resolveBase()}/${path.replace(/^\/+/, "")}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
method: init.method ?? "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
const message: string =
|
||||
json?.message ?? json?.error ?? `HTTP ${res.status}: ${res.statusText}`;
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
//return res.json() as Promise<T>;
|
||||
const text = await res.text();
|
||||
return (text && text.trim().length > 0 ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
// ── Convenience shorthands ────────────────────────────
|
||||
export const get = <T>(path: string, token?: string | null) =>
|
||||
request<T>(path, {}, token);
|
||||
|
||||
export const post = <T>(path: string, body: unknown, token?: string | null) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }, token);
|
||||
|
||||
export const patch = <T>(path: string, body: unknown, token?: string | null) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }, token);
|
||||
export const put = <T>(path: string, body: unknown, token?: string | null) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }, token);
|
||||
|
||||
export const del = <T>(path: string, token?: string | null) =>
|
||||
request<T>(path, { method: "DELETE" }, token);
|
||||
|
||||
|
||||
28
src/services/auth.service.ts
Normal file
28
src/services/auth.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { post } from "./api";
|
||||
import type { LoginResponse } from "@/src/types/auth";
|
||||
|
||||
export interface LoginPayload {
|
||||
identity: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Authenticate a user with email / phone / username + password.
|
||||
* Throws `ApiError` on network or credential failure.
|
||||
*/
|
||||
login: (payload: LoginPayload) =>
|
||||
post<LoginResponse>("auth/login", payload),
|
||||
|
||||
/**
|
||||
* Request a password reset email.
|
||||
*/
|
||||
forgotPassword: (email: string) =>
|
||||
post<{ message: string }>("auth/forgot-password", { email }),
|
||||
|
||||
/**
|
||||
* Confirm a password reset with a token.
|
||||
*/
|
||||
resetPassword: (token: string, newPassword: string) =>
|
||||
post<{ message: string }>("auth/reset-password", { token, newPassword }),
|
||||
};
|
||||
129
src/services/car.service.ts
Normal file
129
src/services/car.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { get, post, patch, del } from "./api";
|
||||
import type {
|
||||
Car,
|
||||
CarListResponse,
|
||||
CarDetailResponse,
|
||||
CarImageListResponse,
|
||||
CreateCarPayload,
|
||||
UpdateCarPayload,
|
||||
} from "@/src/types/car";
|
||||
|
||||
/** Build a query string for list endpoints */
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||
if (!entries.length) return "";
|
||||
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||
}
|
||||
|
||||
export const carService = {
|
||||
/**
|
||||
* GET /cars
|
||||
* Fetch paginated + searchable car list.
|
||||
*/
|
||||
getAll: (
|
||||
page = 1,
|
||||
search = "",
|
||||
token: string | null,
|
||||
) =>
|
||||
get<CarListResponse>(
|
||||
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
|
||||
token,
|
||||
),
|
||||
|
||||
/**
|
||||
* GET /cars/archived
|
||||
* Fetch soft-deleted cars.
|
||||
*/
|
||||
getArchived: (token: string | null) =>
|
||||
get<CarListResponse>("cars/archived", token),
|
||||
|
||||
/**
|
||||
* GET /cars/:id
|
||||
* Fetch a single car with status history.
|
||||
*/
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<CarDetailResponse>(`cars/${id}`, token),
|
||||
|
||||
/**
|
||||
* POST /cars
|
||||
* Create a new car record.
|
||||
*/
|
||||
create: (payload: CreateCarPayload, token: string | null) =>
|
||||
post<{ data: Car }>("cars", payload, token),
|
||||
|
||||
/**
|
||||
* PATCH /cars/:id
|
||||
* Update an existing car's details.
|
||||
*/
|
||||
update: (id: string, payload: UpdateCarPayload, token: string | null) =>
|
||||
patch<{ data: Car }>(`cars/${id}`, payload, token),
|
||||
|
||||
/**
|
||||
* DELETE /cars/:id
|
||||
* Soft-delete a car (returns 204 No Content).
|
||||
*/
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`cars/${id}`, token),
|
||||
|
||||
// ── Images ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /car-images/car/:id
|
||||
* Fetch all active images for a car.
|
||||
* Supports optional query params: day, month, year, date, sortBy.
|
||||
*/
|
||||
getImages: (
|
||||
carId: string,
|
||||
token: string | null,
|
||||
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
|
||||
) =>
|
||||
get<CarImageListResponse>(
|
||||
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
|
||||
token,
|
||||
),
|
||||
|
||||
/**
|
||||
* GET /car-images/car/:id/archive
|
||||
* Fetch soft-deleted images for a car.
|
||||
*/
|
||||
getArchivedImages: (carId: string, token: string | null) =>
|
||||
get<CarImageListResponse>(`car-images/car/${carId}/archive`, token),
|
||||
|
||||
/**
|
||||
* POST /car-images/car/:id (multipart/form-data)
|
||||
* Upload one or more images.
|
||||
* Uses raw fetch because multer expects FormData, not JSON.
|
||||
*/
|
||||
uploadImages: async (
|
||||
carId: string,
|
||||
files: File[],
|
||||
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
|
||||
token: string | null,
|
||||
maintenanceId?: string,
|
||||
): Promise<CarImageListResponse> => {
|
||||
const form = new FormData();
|
||||
files.forEach((f) => form.append("images", f));
|
||||
form.append("stage", stage);
|
||||
if (maintenanceId) form.append("maintenanceId", maintenanceId);
|
||||
|
||||
const res = await fetch(`/api/proxy/car-images/car/${carId}`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<CarImageListResponse>;
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /car-images/:imageId
|
||||
* Soft-delete a single image.
|
||||
*/
|
||||
deleteImage: (imageId: string, token: string | null) =>
|
||||
del<void>(`car-images/${imageId}`, token),
|
||||
};
|
||||
48
src/services/client.service.ts
Normal file
48
src/services/client.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
// Mirrors user.service.ts exactly: token parameter, buildPayload, buildQuery.
|
||||
import { get, post, put, del } from "./api";
|
||||
import type { ApiResponse, ApiListResponse, Client, ClientFormData } from "@/src/types/client";
|
||||
|
||||
/** نوع الاستجابة لعملية واحدة على عميل */
|
||||
export interface ClientResponse {
|
||||
data: Client;
|
||||
}
|
||||
|
||||
/** بناء query string لجلب العملاء مع البحث والصفحات */
|
||||
function buildClientsQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
/** جلب قائمة العملاء مع إمكانية البحث والتصفح بين الصفحات */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<Client>>(`client${buildClientsQuery(page, search)}`, token),
|
||||
|
||||
/** جلب عميل واحد بالـ ID (مع العناوين المرفقة) */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<ApiResponse<Client>>(`client/${id}`, token),
|
||||
|
||||
/** إنشاء عميل جديد */
|
||||
create: (data: ClientFormData, token: string | null) =>
|
||||
post<ClientResponse>("client", buildPayload(data), token),
|
||||
|
||||
/** تعديل بيانات عميل موجود */
|
||||
update: (id: string, data: ClientFormData, token: string | null) =>
|
||||
put<ClientResponse>(`client/${id}`, buildPayload(data), token),
|
||||
|
||||
/** حذف عميل */
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`client/${id}`, token),
|
||||
};
|
||||
|
||||
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
||||
function buildPayload(data: ClientFormData): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
name: data.name.trim(),
|
||||
email: data.email.trim(),
|
||||
phone: data.phone.trim(),
|
||||
};
|
||||
if (data.taxId?.trim()) payload.taxId = data.taxId.trim();
|
||||
if (data.notes?.trim()) payload.notes = data.notes.trim();
|
||||
return payload;
|
||||
}
|
||||
49
src/services/clientAddress.service.ts
Normal file
49
src/services/clientAddress.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Mirrors user.service.ts pattern: explicit token param, buildPayload helper.
|
||||
import { get, post, put, patch, del } from "./api";
|
||||
import type {
|
||||
ApiResponse,
|
||||
ClientAddress,
|
||||
ClientAddressFormData,
|
||||
} from "@/src/types/client";
|
||||
|
||||
const base = (clientId: string) => `client/${clientId}/addresses`;
|
||||
|
||||
export const clientAddressService = {
|
||||
/** جلب جميع عناوين عميل معين */
|
||||
getAll: (clientId: string, token: string | null) =>
|
||||
get<ApiResponse<ClientAddress[]>>(base(clientId), token),
|
||||
|
||||
/** جلب عنوان واحد بالـ ID */
|
||||
getById: (clientId: string, addressId: string, token: string | null) =>
|
||||
get<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, token),
|
||||
|
||||
/** إضافة عنوان جديد لعميل */
|
||||
create: (clientId: string, data: ClientAddressFormData, token: string | null) =>
|
||||
post<ApiResponse<ClientAddress>>(base(clientId), buildPayload(clientId, data), token),
|
||||
|
||||
/** تعديل عنوان موجود */
|
||||
update: (clientId: string, addressId: string, data: ClientAddressFormData, token: string | null) =>
|
||||
put<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}`, buildPayload(clientId, data), token),
|
||||
|
||||
/** حذف عنوان */
|
||||
delete: (clientId: string, addressId: string, token: string | null) =>
|
||||
del<void>(`${base(clientId)}/${addressId}`, token),
|
||||
|
||||
/** تعيين عنوان كعنوان رئيسي — الباكند يُلغي الاختيار عن الباقين */
|
||||
setPrimary: (clientId: string, addressId: string, token: string | null) =>
|
||||
patch<ApiResponse<ClientAddress>>(`${base(clientId)}/${addressId}/primary`, {}, token),
|
||||
};
|
||||
|
||||
/** تحويل بيانات النموذج إلى payload */
|
||||
function buildPayload(clientId: string, data: ClientAddressFormData): Record<string, unknown> {
|
||||
return {
|
||||
clientId,
|
||||
label: data.label.trim(),
|
||||
street: data.street.trim(),
|
||||
city: data.city.trim(),
|
||||
state: data.state.trim(),
|
||||
postalCode: data.postalCode.trim(),
|
||||
country: data.country.trim(),
|
||||
isPrimary: data.isPrimary,
|
||||
};
|
||||
}
|
||||
7
src/services/dashboard.service.ts
Normal file
7
src/services/dashboard.service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { get } from "./api";
|
||||
import type { DashboardSummaryResponse } from "@/src/types/dashboard";
|
||||
|
||||
export const dashboardService = {
|
||||
getSummary: (token: string) =>
|
||||
get<DashboardSummaryResponse>("dashboard/summary", token),
|
||||
};
|
||||
273
src/services/driver.service.ts
Normal file
273
src/services/driver.service.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { get, post, del, patch } from "./api";
|
||||
import type {
|
||||
Driver,
|
||||
DriverListResponse,
|
||||
DriverDetailResponse,
|
||||
DriverReportResponse,
|
||||
CreateDriverPayload,
|
||||
UpdateDriverPayload,
|
||||
} from "@/src/types/driver";
|
||||
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||
if (!entries.length) return "";
|
||||
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||
}
|
||||
|
||||
// ── Image URL normaliser ──────────────────────────────────────────────────────
|
||||
// Returns the URL as-is — <img> tags are not subject to CORS, so we can point
|
||||
// directly at the backend without a Next.js proxy hop.
|
||||
// Only strips query-string noise or fixes obviously malformed paths if needed.
|
||||
|
||||
function normaliseImageUrl(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
function mapDriverImages<
|
||||
T extends {
|
||||
photoUrl?: string | null;
|
||||
nationalPhotoUrl?: string | null;
|
||||
driverCardPhotoUrl?: string | null;
|
||||
},
|
||||
>(driver: T): T {
|
||||
return {
|
||||
...driver,
|
||||
photoUrl: normaliseImageUrl(driver.photoUrl),
|
||||
nationalPhotoUrl: normaliseImageUrl(driver.nationalPhotoUrl),
|
||||
driverCardPhotoUrl: normaliseImageUrl(driver.driverCardPhotoUrl),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Image compression ─────────────────────────────────────────────────────────
|
||||
// Resizes to max 1024px while preserving the original file type and name.
|
||||
|
||||
async function compressImage(file: File, maxPx = 1024, quality = 0.82): Promise<File> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
let { width, height } = img;
|
||||
|
||||
// Guard: malformed/zero-dimension image — bail out to original file
|
||||
if (!width || !height) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only resize if actually oversized
|
||||
if (width <= maxPx && height <= maxPx) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (width >= height) {
|
||||
height = Math.round((height / width) * maxPx);
|
||||
width = maxPx;
|
||||
} else {
|
||||
width = Math.round((width / height) * maxPx);
|
||||
height = maxPx;
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) { resolve(file); return; }
|
||||
|
||||
// PNG/GIF have transparency — paint white behind them first so toBlob
|
||||
// doesn't hand back a canvas with undefined/black fill in some browsers.
|
||||
// (Skip this for formats that don't carry alpha, like plain JPEG.)
|
||||
const mimeType = file.type || "image/jpeg";
|
||||
if (mimeType !== "image/jpeg") {
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// quality param is meaningless (and occasionally mishandled) for
|
||||
// lossless formats — only pass it for jpeg/webp.
|
||||
const isLossy = mimeType === "image/jpeg" || mimeType === "image/webp";
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
// Guard: empty/undersized blob means the encode failed — fall
|
||||
// back to the original file rather than uploading garbage.
|
||||
if (!blob || blob.size < 100) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
resolve(new File([blob], file.name, { type: mimeType, lastModified: Date.now() }));
|
||||
},
|
||||
mimeType,
|
||||
isLossy ? quality : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(file); };
|
||||
img.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function compressPayloadImages<
|
||||
T extends { photo?: File; nationalPhoto?: File; driverCardPhoto?: File },
|
||||
>(payload: T): Promise<T> {
|
||||
const result = { ...payload };
|
||||
if (result.photo) result.photo = await compressImage(result.photo);
|
||||
if (result.nationalPhoto) result.nationalPhoto = await compressImage(result.nationalPhoto);
|
||||
if (result.driverCardPhoto) result.driverCardPhoto = await compressImage(result.driverCardPhoto);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const driverService = {
|
||||
/** GET /driver — paginated + searchable list */
|
||||
getAll: async (page = 1, search = "", token: string | null): Promise<DriverListResponse> => {
|
||||
const res = await get<DriverListResponse>(
|
||||
`driver${buildQuery({ page, limit: 12, search: search || undefined })}`,
|
||||
token,
|
||||
);
|
||||
const body = (res as unknown as { data: { data: Driver[]; pagination: unknown; meta?: unknown } }).data;
|
||||
body.data = body.data.map(mapDriverImages);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** GET /driver/archived */
|
||||
getArchived: async (token: string | null): Promise<DriverListResponse> => {
|
||||
const res = await get<DriverListResponse>("driver/archived", token);
|
||||
const body = (res as unknown as { data: { data: Driver[] } }).data;
|
||||
body.data = body.data.map(mapDriverImages);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** GET /driver/me */
|
||||
getMe: async (token: string | null): Promise<DriverDetailResponse> => {
|
||||
const res = await get<DriverDetailResponse>("driver/me", token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** GET /driver/:id */
|
||||
getById: async (id: string, token: string | null): Promise<DriverDetailResponse> => {
|
||||
const res = await get<DriverDetailResponse>(`driver/${id}`, token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** POST /driver (JSON — no files) */
|
||||
create: (payload: CreateDriverPayload, token: string | null) =>
|
||||
post<{ data: Driver }>("driver", payload, token),
|
||||
|
||||
/** PATCH /driver/:id (JSON — no files) */
|
||||
update: async (id: string, payload: UpdateDriverPayload, token: string | null): Promise<{ data: Driver }> => {
|
||||
const res = await patch<{ data: Driver }>(`driver/${id}`, payload, token);
|
||||
const body = res as unknown as { data: Driver };
|
||||
body.data = mapDriverImages(body.data);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** DELETE /driver/:id */
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`driver/${id}`, token),
|
||||
|
||||
/** GET /drivers/:id/reports/daily?date=YYYY-MM-DD */
|
||||
getDailyReport: (id: string, date: string, token: string | null) =>
|
||||
get<DriverReportResponse>(
|
||||
`drivers/${id}/reports/daily?date=${encodeURIComponent(date)}`,
|
||||
token,
|
||||
),
|
||||
|
||||
// ── Multipart helpers (with auto compression) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /driver (multipart/form-data)
|
||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
||||
*/
|
||||
createWithImages: async (
|
||||
payload: CreateDriverPayload & {
|
||||
photo?: File;
|
||||
nationalPhoto?: File;
|
||||
driverCardPhoto?: File;
|
||||
},
|
||||
token: string | null,
|
||||
): Promise<{ data: Driver }> => {
|
||||
const compressed = await compressPayloadImages(payload);
|
||||
|
||||
const form = new FormData();
|
||||
Object.entries(compressed).forEach(([key, val]) => {
|
||||
if (val !== undefined && val !== null && !(val instanceof File)) {
|
||||
form.append(key, String(val));
|
||||
}
|
||||
});
|
||||
if (compressed.photo) form.append("photo", compressed.photo);
|
||||
if (compressed.nationalPhoto) form.append("nationalPhoto", compressed.nationalPhoto);
|
||||
if (compressed.driverCardPhoto) form.append("driverCardPhoto", compressed.driverCardPhoto);
|
||||
|
||||
const res = await fetch("/api/proxy/driver", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { data: Driver };
|
||||
data.data = mapDriverImages(data.data);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /driver/:id (multipart/form-data)
|
||||
* Compresses images before upload to stay under backend LIMIT_FILE_SIZE.
|
||||
*/
|
||||
updateWithImages: async (
|
||||
id: string,
|
||||
payload: UpdateDriverPayload & {
|
||||
photo?: File;
|
||||
nationalPhoto?: File;
|
||||
driverCardPhoto?: File;
|
||||
},
|
||||
token: string | null,
|
||||
): Promise<{ data: Driver }> => {
|
||||
const compressed = await compressPayloadImages(payload);
|
||||
|
||||
const form = new FormData();
|
||||
Object.entries(compressed).forEach(([key, val]) => {
|
||||
if (val !== undefined && val !== null && !(val instanceof File)) {
|
||||
form.append(key, String(val));
|
||||
}
|
||||
});
|
||||
if (compressed.photo) form.append("photo", compressed.photo);
|
||||
if (compressed.nationalPhoto) form.append("nationalPhoto", compressed.nationalPhoto);
|
||||
if (compressed.driverCardPhoto) form.append("driverCardPhoto", compressed.driverCardPhoto);
|
||||
|
||||
const res = await fetch(`/api/proxy/driver/${id}`, {
|
||||
method: "PATCH",
|
||||
// Do NOT set Content-Type — browser sets it with the correct boundary
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as { data: Driver };
|
||||
data.data = mapDriverImages(data.data);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
11
src/services/index.ts
Normal file
11
src/services/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { request, get, post, put, patch, del, ApiError } from "./api";
|
||||
export { authService } from "./auth.service";
|
||||
export { dashboardService } from "./dashboard.service";
|
||||
export { clientService } from "./client.service";
|
||||
export { clientAddressService } from "./clientAddress.service";
|
||||
export { userService } from "./user.service";
|
||||
export { carService } from "./car.service";
|
||||
export { driverService } from "./driver.service";
|
||||
export { tripService } from "./trip.service";
|
||||
export { orderService } from "./order.service";
|
||||
export { roleService } from "./role.service";
|
||||
45
src/services/order.service.ts
Normal file
45
src/services/order.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// services/order.service.ts
|
||||
import { get, post, put, del } from "./api";
|
||||
import type {
|
||||
Order,
|
||||
CreateOrderPayload,
|
||||
UpdateOrderPayload,
|
||||
UpdateOrderStatusPayload,
|
||||
TransferOrderPayload,
|
||||
OrdersResponse,
|
||||
} from "@/src/types/order";
|
||||
|
||||
export const orderService = {
|
||||
/** Get all orders (paginated) */
|
||||
getAll: (params?: Record<string, string | number>) => {
|
||||
const qs = params
|
||||
? "?" + new URLSearchParams(params as Record<string, string>).toString()
|
||||
: "";
|
||||
return get<OrdersResponse>(`orders${qs}`);
|
||||
},
|
||||
|
||||
/** Get order by ID */
|
||||
getById: (id: string) => get<Order>(`orders/${id}`),
|
||||
|
||||
/** Create new order */
|
||||
create: (payload: CreateOrderPayload) =>
|
||||
post<Order>("orders", payload),
|
||||
|
||||
/** Update order metadata */
|
||||
update: (id: string, payload: UpdateOrderPayload) =>
|
||||
put<Order>(`orders/${id}`, payload),
|
||||
|
||||
/** Update order status with optional reason */
|
||||
updateStatus: (id: string, payload: UpdateOrderStatusPayload) =>
|
||||
put<Order>(`orders/${id}/status`, payload),
|
||||
|
||||
/** Transfer order to a different trip */
|
||||
transfer: (id: string, payload: TransferOrderPayload) =>
|
||||
put<Order>(`orders/${id}/transfer`, payload),
|
||||
|
||||
/** Soft-delete an order */
|
||||
delete: (id: string) => del<void>(`orders/${id}`),
|
||||
|
||||
/** Get archived orders */
|
||||
getArchived: () => get<OrdersResponse>("orders/archived"),
|
||||
};
|
||||
95
src/services/role.service.ts
Normal file
95
src/services/role.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { requestJson } from "@/src/lib/api";
|
||||
import { get, post, put, del, patch } from "./api";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
permissions?: Array<{
|
||||
permission: Permission;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RoleFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
permissionIds: string[];
|
||||
}
|
||||
|
||||
export interface ApiPaginatedResponse<T> {
|
||||
data: {
|
||||
data: T[];
|
||||
pagination?: { total: number; page: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoleResponse {
|
||||
data: Role;
|
||||
}
|
||||
|
||||
export interface PermissionsResponse {
|
||||
data: {
|
||||
premissions: Permission[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Build query string for paginated role listing */
|
||||
function buildRolesQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const roleService = {
|
||||
/** Fetch paginated roles list */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiPaginatedResponse<Role>>(`role${buildRolesQuery(page, search)}`, token),
|
||||
|
||||
/** Fetch a single role by ID (includes permissions) */
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<{ data: Role }>(`role/${id}`, token),
|
||||
|
||||
/** Create a new role */
|
||||
create: (data: RoleFormData, token: string | null) =>
|
||||
post<RoleResponse>("role", { name: data.name, description: data.description }, token),
|
||||
|
||||
|
||||
/** Update role name/description */
|
||||
update: (id: string, data: Partial<RoleFormData>, token: string | null) =>
|
||||
put<RoleResponse>(`role/${id}`, { name: data.name, description: data.description }, token),
|
||||
|
||||
|
||||
/** Soft-delete a role */
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`role/${id}`, token),
|
||||
|
||||
/** Fetch all available permissions for checkbox list */
|
||||
getPermissions: (token: string | null) =>
|
||||
get<PermissionsResponse>("premission?limit=200", token),
|
||||
|
||||
/** Assign a single permission to a role */
|
||||
assignPermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||
post<unknown>(`role/${roleId}/permissions`, { permissionId }, token),
|
||||
|
||||
/** Remove a permission from a role */
|
||||
removePermission: (roleId: string, permissionId: string, token: string | null) =>
|
||||
del<unknown>(`role/${roleId}/permissions/${permissionId}`, token),
|
||||
/** Atomically replace all permissions assigned to a role */
|
||||
setPermissions: (roleId: string, permissionIds: string[], token: string | null) =>
|
||||
patch<{ data: Role }>(`role/${roleId}/permissions`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ permissionIds }),
|
||||
}, token),
|
||||
|
||||
};
|
||||
109
src/services/trip.service.ts
Normal file
109
src/services/trip.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { get, post, patch, del } from "./api";
|
||||
import type {
|
||||
Trip,
|
||||
TripListResponse,
|
||||
TripDetailResponse,
|
||||
TripReportResponse,
|
||||
CreateTripPayload,
|
||||
UpdateTripPayload,
|
||||
TripListParams,
|
||||
} from "@/src/types/trip";
|
||||
|
||||
/** Build a query string for list endpoints */
|
||||
function buildQuery(
|
||||
params: Record<string, string | number | undefined>,
|
||||
): string {
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => v !== undefined && v !== "",
|
||||
);
|
||||
if (!entries.length) return "";
|
||||
return (
|
||||
"?" +
|
||||
entries
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join("&")
|
||||
);
|
||||
}
|
||||
|
||||
export const tripService = {
|
||||
/**
|
||||
* GET /trip
|
||||
* Fetch paginated + filterable + searchable trip list.
|
||||
* Requires: "read-trip" permission
|
||||
*/
|
||||
getAll: (params: TripListParams = {}, token: string | null) =>
|
||||
get<TripListResponse>(`trip${buildQuery({ ...params })}`, token),
|
||||
|
||||
/**
|
||||
* GET /trip/archived
|
||||
* Fetch soft-deleted (archived) trips. Same filters as getAll.
|
||||
* Requires: "read-deleted-trip" permission
|
||||
*/
|
||||
getArchived: (params: TripListParams = {}, token: string | null) =>
|
||||
get<TripListResponse>(`trip/archived${buildQuery({ ...params })}`, token),
|
||||
|
||||
/**
|
||||
* GET /trip/archived/:id
|
||||
* Fetch a single archived trip with full details.
|
||||
* Requires: "read-deleted-trip" permission
|
||||
*/
|
||||
getArchivedById: (id: string, token: string | null) =>
|
||||
get<TripDetailResponse>(`trip/archived/${id}`, token),
|
||||
|
||||
/**
|
||||
* GET /trip/:id
|
||||
* Fetch a single trip with full driver/car details.
|
||||
* Requires: "read-trip" permission
|
||||
*/
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<TripDetailResponse>(`trip/${id}`, token),
|
||||
|
||||
/**
|
||||
* POST /trip
|
||||
* Create a new trip.
|
||||
* Side effect: driver & car flip to status "InTrip" automatically.
|
||||
* Requires: "create-trip" permission
|
||||
*/
|
||||
create: (payload: CreateTripPayload, token: string | null) =>
|
||||
post<{ data: Trip }>("trip", payload, token),
|
||||
|
||||
/**
|
||||
* PATCH /trip/:id
|
||||
* Update trip data / status / reassign driver or car.
|
||||
* Side effects:
|
||||
* - changing driverId: old driver -> Active, new driver -> InTrip
|
||||
* - changing carId: same logic for cars
|
||||
* - status Completed/Cancelled: driver & car both -> Active
|
||||
* Requires: "update-trip" permission
|
||||
*/
|
||||
update: (id: string, payload: UpdateTripPayload, token: string | null) =>
|
||||
patch<{ data: Trip }>(`trip/${id}`, payload, token),
|
||||
|
||||
/**
|
||||
* DELETE /trip/:id
|
||||
* Soft-delete a trip (isDeleted=true, deletedAt=now()). 204 No Content.
|
||||
* Requires: "delete-trip" permission
|
||||
*/
|
||||
delete: (id: string, token: string | null) =>
|
||||
del<void>(`trip/${id}`, token),
|
||||
|
||||
// ── Reports ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /trip/:id/reports
|
||||
* Generate the Trip Manifest report (HTML, saved server-side).
|
||||
* Returns { reportUrl }
|
||||
* Requires: "generate-trip-report" permission
|
||||
*/
|
||||
getReport: (id: string, token: string | null) =>
|
||||
get<TripReportResponse>(`trip/${id}/reports`, token),
|
||||
|
||||
/**
|
||||
* GET /trip/:id/reports/client/:clientId
|
||||
* Generate a client-filtered version of the trip report.
|
||||
* Returns { reportUrl }
|
||||
* Requires: "generate-trip-report" permission
|
||||
*/
|
||||
getClientReport: (id: string, clientId: string, token: string | null) =>
|
||||
get<TripReportResponse>(`trip/${id}/reports/client/${clientId}`, token),
|
||||
};
|
||||
71
src/services/user.service.ts
Normal file
71
src/services/user.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// خدمة المستخدمين — كل نداءات الـ API الخاصة بالمستخدمين تكون هنا فقط
|
||||
import { get, post, put, del } from "./api";
|
||||
import type { ApiListResponse, User, UserFormData } from "@/src/types/user";
|
||||
import type { Role } from "@/src/types/role";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
/** نوع الاستجابة لعملية واحدة على مستخدم */
|
||||
export interface UserResponse {
|
||||
data: User;
|
||||
}
|
||||
|
||||
/** بناء query string لجلب المستخدمين مع البحث والصفحات */
|
||||
function buildUsersQuery(page: number, search: string): string {
|
||||
return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
}
|
||||
|
||||
export const userService = {
|
||||
/** جلب قائمة المستخدمين مع إمكانية البحث والتصفح بين الصفحات */
|
||||
getAll: (page: number, search: string, token: string | null) =>
|
||||
get<ApiListResponse<User>>(`users${buildUsersQuery(page, search)}`, token),
|
||||
|
||||
/** إنشاء مستخدم جديد */
|
||||
create: (
|
||||
data: Omit<UserFormData, "password"> & { password: string },
|
||||
token: string | null,
|
||||
) => post<UserResponse>("users", buildPayload(data, true), token),
|
||||
|
||||
/** تعديل بيانات مستخدم موجود */
|
||||
update: (id: string, data: UserFormData, token: string | null) =>
|
||||
put<UserResponse>(`users/${id}`, buildPayload(data, false), token),
|
||||
|
||||
/** حذف مستخدم */
|
||||
delete: (id: string, token: string | null) => del<void>(`users/${id}`, token),
|
||||
//get role
|
||||
getRoles: (token: string | null) =>
|
||||
get<{ data: { data: Role[] } }>("role?limit=100", token),
|
||||
//get Branches for user form
|
||||
getBranches: (token: string | null) =>
|
||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token),
|
||||
//get user by id
|
||||
getById: (id: string, token: string | null) =>
|
||||
get<{
|
||||
data: User & {
|
||||
photo: string | null;
|
||||
refreshToken: string | null;
|
||||
isDeleted: boolean;
|
||||
deletedAt: string | null;
|
||||
updatedAt: string;
|
||||
passwordChangedAt: string | null;
|
||||
role: { name: string; description: string };
|
||||
branch: { name: string };
|
||||
};
|
||||
}>(`users/${id}`, token),
|
||||
};
|
||||
|
||||
/** تحويل بيانات النموذج إلى payload مناسب للـ API */
|
||||
function buildPayload(
|
||||
data: UserFormData,
|
||||
isNew: boolean,
|
||||
): Record<string, string> {
|
||||
const payload: Record<string, string> = {
|
||||
name: data.name.trim(),
|
||||
phone: data.phone.trim(),
|
||||
roleId: data.roleId,
|
||||
branchId: data.branchId,
|
||||
};
|
||||
if (data.email) payload.email = data.email.trim();
|
||||
if (isNew && data.password) payload.password = data.password;
|
||||
else if (!isNew && data.password) payload.password = data.password;
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user