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:
m7amedez5511
2026-06-23 15:54:07 +03:00
parent c1b77f89cd
commit db64b79fe3
140 changed files with 9266 additions and 2449 deletions

72
src/services/api.ts Normal file
View 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);

View 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
View 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),
};

View 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;
}

View 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,
};
}

View 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),
};

View 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
View 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";

View 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"),
};

View 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),
};

View 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),
};

View 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;
}