Auto commit: 2026-06-04T18:38:09.342Z

This commit is contained in:
Flatlogic Bot 2026-06-04 18:38:09 +00:00
parent 5226283cc7
commit 6c0b5d5c8b
20 changed files with 870 additions and 102 deletions

View File

@ -154,6 +154,12 @@ router.post('/bulk-import', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
router.put('/:id/status', wrapAsync(async (req, res) => {
const payload = await OrdersService.changeStatus(req.params.id, req.body.data || {}, req.currentUser);
res.status(200).send(payload);
}));
/**
* @swagger
* /api/orders/{id}:

View File

@ -3,11 +3,60 @@ const OrdersDBApi = require('../db/api/orders');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const ORDER_STATUS_TRANSITIONS = {
pending: ['assigned', 'cancelled'],
assigned: ['picked_up'],
picked_up: ['out_for_delivery'],
out_for_delivery: ['delivered', 'failed_attempt'],
failed_attempt: ['rescheduled'],
rescheduled: ['out_for_delivery'],
delivered: [],
cancelled: [],
returned: [],
};
const STATUS_ROLE_RULES = {
assigned: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor'],
picked_up: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'],
out_for_delivery: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'],
delivered: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User'],
failed_attempt: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Driver User', 'Customer Support Agent'],
rescheduled: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Customer Support Agent'],
cancelled: ['Administrator', 'System Owner', 'Operations Manager', 'Dispatch Supervisor', 'Customer Support Agent'],
};
function getRoleName(currentUser) {
return currentUser?.app_role?.name || currentUser?.role || 'Unknown';
}
function validateStatusTransition(order, nextStatus, currentUser) {
if (!nextStatus || !ORDER_STATUS_TRANSITIONS[nextStatus]) {
throw new ValidationError('orders.invalidStatus', `Invalid order status '${nextStatus}'.`);
}
const currentStatus = order.current_status || 'pending';
const allowedNext = ORDER_STATUS_TRANSITIONS[currentStatus] || [];
if (!allowedNext.includes(nextStatus)) {
throw new ValidationError(
'orders.invalidTransition',
`Cannot transition order from '${currentStatus}' to '${nextStatus}'.`,
);
}
const roleName = getRoleName(currentUser);
const allowedRoles = STATUS_ROLE_RULES[nextStatus] || [];
if (!allowedRoles.includes(roleName)) {
throw new ValidationError(
'orders.statusRoleForbidden',
`Role '${roleName}' cannot change an order to '${nextStatus}'.`,
);
}
}
@ -28,9 +77,9 @@ module.exports = class OrdersService {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -65,10 +114,71 @@ module.exports = class OrdersService {
}
}
static async changeStatus(id, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const order = await db.orders.findByPk(id, { transaction });
if (!order) {
throw new ValidationError('ordersNotFound');
}
const nextStatus = data.current_status;
validateStatusTransition(order, nextStatus, currentUser);
if (nextStatus === 'assigned' && !data.assigned_driver && !order.assigned_driverId) {
throw new ValidationError('orders.driverRequired', 'Assigned status requires a driver.');
}
const previousStatus = order.current_status || 'pending';
const updatePayload = {
previous_status: previousStatus,
current_status: nextStatus,
last_status_at: new Date(),
updatedById: currentUser?.id || null,
};
if (data.assigned_driver !== undefined) {
updatePayload.assigned_driverId = data.assigned_driver || null;
}
if (data.delivery_lat !== undefined) {
updatePayload.delivery_lat = data.delivery_lat || null;
}
if (data.delivery_lng !== undefined) {
updatePayload.delivery_lng = data.delivery_lng || null;
}
await order.update(updatePayload, { transaction });
await db.order_status_logs.create(
{
orderId: id,
from_status: previousStatus,
to_status: nextStatus,
comment: data.comment || null,
changed_at: new Date(),
changed_by_userId: currentUser?.id || null,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
await transaction.commit();
return OrdersDBApi.findBy({ id });
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let orders = await OrdersDBApi.findBy(
const orders = await OrdersDBApi.findBy(
{id},
{transaction},
);
@ -95,7 +205,7 @@ module.exports = class OrdersService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();

View File

@ -19,7 +19,7 @@ export default function AsideMenu({
<>
<AsideMenuLayer
menu={props.menu}
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
className={`${isAsideMobileExpanded ? 'right-0' : '-right-60 lg:right-0'} ${
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
}`}
onAsideLgCloseClick={props.onAsideLgClose}

View File

@ -29,7 +29,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
return (
<aside
id='asideMenu'
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
className={`${className} zzz lg:py-2 lg:pr-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
>
<div
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
@ -37,7 +37,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<div className="text-center flex-1 lg:text-right lg:pr-6 xl:text-center xl:pr-0">
<b className="font-black">Parcel Ops RTL</b>

View File

@ -12,7 +12,7 @@ export default function FooterBar({ children }: Props) {
return (
<footer className={`py-2 px-6 ${containerMaxW}`}>
<div className="block md:flex items-center justify-between">
<div className="text-center md:text-left mb-6 md:mb-0">
<div className="text-center md:text-right mb-6 md:mb-0">
<b>
&copy;{year},{` `}
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">

View File

@ -47,7 +47,7 @@ export default function NavBar({ menu, className = '', children }: Props) {
<div
className={`${
isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 right-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
>
<NavBarMenuList menu={menu} />
</div>

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
@ -66,9 +65,12 @@ export default function NavBarItem({ item }: Props) {
const getItemId = (label) => {
switch (label) {
case 'وضع الإضاءة':
case 'Light/Dark':
return 'themeToggle';
case 'تسجيل الخروج':
case 'Log out':
case 'Log Out':
return 'logout';
default:
return undefined;
@ -94,7 +96,7 @@ export default function NavBarItem({ item }: Props) {
>
{itemLabel}
</span>
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 ml-3 inline-flex" />}
{item.menu && (
<BaseIcon
path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
@ -106,7 +108,7 @@ export default function NavBarItem({ item }: Props) {
<div
className={`${
!isDropdownActive ? 'lg:hidden' : ''
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-dark-900 dark:border-dark-700`}
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:right-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-dark-900 dark:border-dark-700`}
>
<ClickOutside onClickOutside={() => setIsDropdownActive(false)} excludedElements={[excludedRef]}>
<NavBarMenuList menu={item.menu} />

View File

@ -49,16 +49,16 @@ export default function PasswordSetOrReset() {
return (
<>
<Head>
{isInvitation && <title>{getPageTitle('Set Password')}</title>}
{!isInvitation && <title>{getPageTitle('Reset Password')}</title>}
{isInvitation && <title>{getPageTitle('تعيين كلمة المرور')}</title>}
{!isInvitation && <title>{getPageTitle('إعادة تعيين كلمة المرور')}</title>}
</Head>
<SectionFullScreen bg='violet'>
<div className='w-full flex flex-col items-center justify-center'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
{isInvitation && <p className='text-xl mb-2'>Set Password</p>}
{!isInvitation && <p className='text-xl mb-2'>Reset Password</p>}
<p className='text-base mb-4'>Enter your new password</p>
{isInvitation && <p className='text-xl mb-2'>تعيين كلمة المرور</p>}
{!isInvitation && <p className='text-xl mb-2'>إعادة تعيين كلمة المرور</p>}
<p className='text-base mb-4'>أدخل كلمة المرور الجديدة</p>
<Formik
initialValues={{
@ -74,7 +74,7 @@ export default function PasswordSetOrReset() {
<Field
type='password'
name='password'
placeholder='Password'
placeholder='كلمة المرور'
/>
</FormField>
<FormField
@ -82,7 +82,7 @@ export default function PasswordSetOrReset() {
<Field
type='password'
name='confirm'
placeholder='Confirm Password'
placeholder='تأكيد كلمة المرور'
/>
</FormField>
@ -93,10 +93,10 @@ export default function PasswordSetOrReset() {
disabled={loading}
label={
loading
? 'Loading...'
? 'جارٍ التحميل...'
: isInvitation
? 'Set Password'
: 'Reset Password'
? 'تعيين كلمة المرور'
: 'إعادة تعيين كلمة المرور'
}
color='info'
/>

View File

@ -11,9 +11,9 @@ const Search = () => {
const validateSearch = (value) => {
let error;
if (!value) {
error = 'Required';
error = 'مطلوب';
} else if (value.length < 2) {
error = 'Minimum length: 2 characters';
error = 'الحد الأدنى حرفان';
}
return error;
};
@ -36,11 +36,11 @@ const Search = () => {
id='search'
name='search'
validate={validateSearch}
placeholder='Search'
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
placeholder='بحث'
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative mr-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
/>
{errors.search && touched.search && values.search.length < 2 ? (
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div>
<div className='text-red-500 text-sm mr-2 absolute'>{errors.search}</div>
) : null}
</Form>
)}

View File

@ -12,6 +12,17 @@
@import "_theme.css";
@import '_rich-text.css';
html[dir='rtl'] body {
direction: rtl;
font-family: Cairo, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
}
html[dir='rtl'] input,
html[dir='rtl'] textarea,
html[dir='rtl'] select {
text-align: right;
}
.introjs-tooltip {
@apply min-w-[400px] max-w-[480px] p-2 !important;

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
@ -86,24 +85,24 @@ export default function LayoutAuthenticated({
}, [router.events, dispatch])
const layoutAsidePadding = 'xl:pl-60'
const layoutAsidePadding = 'xl:pr-60'
return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
isAsideMobileExpanded ? 'mr-60 lg:mr-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'mr-60 lg:mr-0' : ''}`}
>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
<BaseIcon path={isAsideMobileExpanded ? mdiForwardburger : mdiBackburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
@ -122,7 +121,7 @@ export default function LayoutAuthenticated({
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
<FooterBar>صُنع بعناية و </FooterBar>
</div>
</div>
)

View File

@ -2,15 +2,22 @@ import * as icon from '@mdi/js';
import { MenuAsideItem } from './interfaces'
const menuAside: MenuAsideItem[] = [
{
href: '/operations/orders',
icon: icon.mdiPackageVariantClosed,
label: 'عمليات التوصيل',
permissions: 'READ_ORDERS'
},
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
label: 'لوحة التحكم',
},
{
href: '/users/users-list',
label: 'Users',
label: 'المستخدمون',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
@ -18,7 +25,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/roles/roles-list',
label: 'Roles',
label: 'الأدوار',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
@ -26,7 +33,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
label: 'الصلاحيات',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
@ -34,7 +41,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/merchants/merchants-list',
label: 'Merchants',
label: 'التجار',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -42,7 +49,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/drivers/drivers-list',
label: 'Drivers',
label: 'السائقون',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTruckFast' in icon ? icon['mdiTruckFast' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -50,7 +57,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/countries/countries-list',
label: 'Countries',
label: 'الدول',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiEarth' in icon ? icon['mdiEarth' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -58,7 +65,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/cities/cities-list',
label: 'Cities',
label: 'المدن',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCity' in icon ? icon['mdiCity' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -66,7 +73,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/regions/regions-list',
label: 'Regions',
label: 'المناطق',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -74,7 +81,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/merchant_pricing_rules/merchant_pricing_rules-list',
label: 'Merchant pricing rules',
label: 'قواعد تسعير التجار',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -82,7 +89,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/orders/orders-list',
label: 'Orders',
label: 'الطلبات',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPackageVariantClosed' in icon ? icon['mdiPackageVariantClosed' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -90,7 +97,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/order_status_logs/order_status_logs-list',
label: 'Order status logs',
label: 'سجل حالات الطلب',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTimelineText' in icon ? icon['mdiTimelineText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -98,7 +105,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/driver_locations/driver_locations-list',
label: 'Driver locations',
label: 'مواقع السائقين',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCrosshairsGps' in icon ? icon['mdiCrosshairsGps' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -106,7 +113,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/driver_payment_slips/driver_payment_slips-list',
label: 'Driver payment slips',
label: 'كشوفات دفع السائقين',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiReceiptText' in icon ? icon['mdiReceiptText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -114,7 +121,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/merchant_payment_slips/merchant_payment_slips-list',
label: 'Merchant payment slips',
label: 'كشوفات دفع التجار',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileDocumentCheck' in icon ? icon['mdiFileDocumentCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -122,7 +129,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/driver_return_slips/driver_return_slips-list',
label: 'Driver return slips',
label: 'مرتجعات السائقين',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiPackageReturn' in icon ? icon['mdiPackageReturn' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -130,7 +137,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/merchant_return_slips/merchant_return_slips-list',
label: 'Merchant return slips',
label: 'مرتجعات التجار',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardArrowLeft' in icon ? icon['mdiClipboardArrowLeft' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -138,7 +145,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/notifications/notifications-list',
label: 'Notifications',
label: 'الإشعارات',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -146,7 +153,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/system_settings/system_settings-list',
label: 'System settings',
label: 'إعدادات النظام',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCog' in icon ? icon['mdiCog' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
@ -154,7 +161,7 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/profile',
label: 'Profile',
label: 'الملف الشخصي',
icon: icon.mdiAccountCircle,
},
@ -162,7 +169,7 @@ const menuAside: MenuAsideItem[] = [
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
label: 'توثيق Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
},

View File

@ -19,7 +19,7 @@ const menuNavBar: MenuNavBarItem[] = [
menu: [
{
icon: mdiAccount,
label: 'My Profile',
label: 'الملف الشخصي',
href: '/profile',
},
{
@ -27,20 +27,20 @@ const menuNavBar: MenuNavBarItem[] = [
},
{
icon: mdiLogout,
label: 'Log Out',
label: 'تسجيل الخروج',
isLogout: true,
},
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
label: 'وضع الإضاءة',
isDesktopNoLabel: true,
isToggleLightDark: true,
},
{
icon: mdiLogout,
label: 'Log out',
label: 'تسجيل الخروج',
isDesktopNoLabel: true,
isLogout: true,
},

View File

@ -0,0 +1,13 @@
import { Head, Html, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="ar" dir="rtl">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View File

@ -24,21 +24,21 @@ export default function Forgot() {
try {
const { data: response } = await axios.post('/auth/send-password-reset-email', value);
setLoading(false)
notify('success', 'Please check your email for verification link');
notify('success', 'تحقق من بريدك الإلكتروني لرابط إعادة التعيين');
setTimeout(async () => {
await router.push('/login')
}, 3000)
} catch (error) {
setLoading(false)
console.log('error: ', error)
notify('error', 'Something was wrong. Try again')
notify('error', 'حدث خطأ. حاول مرة أخرى')
}
};
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('نسيت كلمة المرور')}</title>
</Head>
<SectionFullScreen bg='violet'>
@ -50,7 +50,7 @@ export default function Forgot() {
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='Email' help='Please enter your email'>
<FormField label='البريد الإلكتروني' help='الرجاء إدخال البريد الإلكتروني'>
<Field name='email' />
</FormField>
@ -59,12 +59,12 @@ export default function Forgot() {
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Submit' }
label={loading ? 'جارٍ التحميل...' : 'إرسال' }
color='info'
/>
<BaseButton
href={'/login'}
label={'Login'}
label={'تسجيل الدخول'}
color='info'
/>
</BaseButtons>

View File

@ -109,8 +109,8 @@ export default function Login() {
backgroundRepeat: 'no-repeat',
}}>
<div className="flex justify-center w-full bg-blue-300/20">
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
by {image?.photographer} on Pexels</a>
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">صورة
بواسطة {image?.photographer} على Pexels</a>
</div>
</div>
)
@ -126,7 +126,7 @@ export default function Login() {
muted
>
<source src={video.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
المتصفح الحالي لا يدعم عرض الفيديو.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
@ -135,7 +135,7 @@ export default function Login() {
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
فيديو بواسطة {video.user.name} على Pexels
</a>
</div>
</div>)
@ -154,7 +154,7 @@ export default function Login() {
backgroundRepeat: 'no-repeat',
} : {}}>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('تسجيل الدخول')}</title>
</Head>
<SectionFullScreen bg='violet'>
@ -170,18 +170,18 @@ export default function Login() {
<div className='flex flex-row text-gray-500 justify-between'>
<div>
<p className='mb-2'>Use{' '}
<p className='mb-2'>استخدم{' '}
<code className={`cursor-pointer ${textColor} `}
data-password="2fa67594"
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>2fa67594</code>{' / '}
to login as Admin</p>
<p>Use <code
لتسجيل الدخول كمدير</p>
<p>استخدم <code
className={`cursor-pointer ${textColor} `}
data-password="61f389762427"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>61f389762427</code>{' / '}
to login as User</p>
لتسجيل الدخول كمستخدم</p>
</div>
<div>
<BaseIcon
@ -203,19 +203,19 @@ export default function Login() {
>
<Form>
<FormField
label='Login'
help='Please enter your login'>
label='البريد الإلكتروني'
help='الرجاء إدخال البريد الإلكتروني'>
<Field name='email' />
</FormField>
<div className='relative'>
<FormField
label='Password'
help='Please enter your password'>
label='كلمة المرور'
help='الرجاء إدخال كلمة المرور'>
<Field name='password' type={showPassword ? 'text' : 'password'} />
</FormField>
<div
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
className='absolute bottom-8 left-0 pl-3 flex items-center cursor-pointer'
onClick={togglePasswordVisibility}
>
<BaseIcon
@ -227,12 +227,12 @@ export default function Login() {
</div>
<div className={'flex justify-between'}>
<FormCheckRadio type='checkbox' label='Remember'>
<FormCheckRadio type='checkbox' label='تذكرني'>
<Field type='checkbox' name='remember' />
</FormCheckRadio>
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
Forgot password?
نسيت كلمة المرور؟
</Link>
</div>
@ -242,16 +242,16 @@ export default function Login() {
<BaseButton
className={'w-full'}
type='submit'
label={isFetching ? 'Loading...' : 'Login'}
label={isFetching ? 'جارٍ التحميل...' : 'تسجيل الدخول'}
color='info'
disabled={isFetching}
/>
</BaseButtons>
<br />
<p className={'text-center'}>
Dont have an account yet?{' '}
ليس لديك حساب بعد؟{' '}
<Link className={`${textColor}`} href={'/register'}>
New Account
إنشاء حساب جديد
</Link>
</p>
</Form>
@ -261,9 +261,9 @@ export default function Login() {
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. جميع الحقوق محفوظة</p>
<Link className='py-6 mr-4 text-sm' href='/privacy-policy/'>
سياسة الخصوصية
</Link>
</div>
<ToastContainer />

View File

@ -0,0 +1,589 @@
import axios from 'axios'
import Head from 'next/head'
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import CardBox from '../../components/CardBox'
import BaseButton from '../../components/BaseButton'
import { getPageTitle } from '../../config'
import { useAppSelector } from '../../stores/hooks'
type Option = {
id: string
label?: string
name?: string
company_name?: string
name_ar?: string
name_en?: string
}
type Order = {
id: string
order_code?: string
customer_name?: string
customer_phone?: string
delivery_address?: string
package_description?: string
weight_size?: string
cod_amount?: string | number
current_status?: string
previous_status?: string
notes?: string
delivery_lat?: string | number
delivery_lng?: string | number
createdAt?: string
last_status_at?: string
merchant?: Option
delivery_city?: Option
delivery_region?: Option
assigned_driver?: Option
order_status_logs_order?: StatusLog[]
}
type StatusLog = {
id: string
from_status?: string
to_status?: string
comment?: string
changed_at?: string
createdAt?: string
}
type FormState = {
merchant: string
customer_name: string
customer_phone: string
delivery_city: string
delivery_region: string
delivery_address: string
package_description: string
weight_size: string
cod_amount: string
notes: string
}
const teal = '#01696f'
const statusMeta = {
pending: { label: 'قيد الانتظار', color: 'bg-slate-100 text-slate-700 ring-slate-200' },
assigned: { label: 'تم التعيين', color: 'bg-blue-100 text-blue-700 ring-blue-200' },
picked_up: { label: 'تم الاستلام', color: 'bg-yellow-100 text-yellow-800 ring-yellow-200' },
out_for_delivery: { label: 'في الطريق للتسليم', color: 'bg-orange-100 text-orange-700 ring-orange-200' },
delivered: { label: 'تم التسليم', color: 'bg-emerald-100 text-emerald-700 ring-emerald-200' },
failed_attempt: { label: 'محاولة فاشلة', color: 'bg-red-100 text-red-700 ring-red-200' },
rescheduled: { label: 'أعيدت الجدولة', color: 'bg-cyan-100 text-cyan-700 ring-cyan-200' },
cancelled: { label: 'ملغي', color: 'bg-zinc-200 text-zinc-800 ring-zinc-300' },
returned: { label: 'مرتجع', color: 'bg-purple-100 text-purple-700 ring-purple-200' },
}
const nextStatuses = {
pending: ['assigned', 'cancelled'],
assigned: ['picked_up'],
picked_up: ['out_for_delivery'],
out_for_delivery: ['delivered', 'failed_attempt'],
failed_attempt: ['rescheduled'],
rescheduled: ['out_for_delivery'],
delivered: [],
cancelled: [],
returned: [],
}
const emptyForm: FormState = {
merchant: '',
customer_name: '',
customer_phone: '',
delivery_city: '',
delivery_region: '',
delivery_address: '',
package_description: '',
weight_size: '',
cod_amount: '',
notes: '',
}
const labelFor = (status?: string) => statusMeta[status || 'pending']?.label || status || '—'
const badgeClass = (status?: string) =>
`inline-flex items-center rounded-full px-3 py-1 text-xs font-bold ring-1 ${
statusMeta[status || 'pending']?.color || statusMeta.pending.color
}`
const optionLabel = (option?: Option) =>
option?.company_name || option?.name_ar || option?.name || option?.label || option?.name_en || '—'
const todayIso = () => new Date().toISOString().slice(0, 10)
const OperationsOrdersPage = () => {
const { currentUser } = useAppSelector((state) => state.auth)
const [orders, setOrders] = useState<Order[]>([])
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
const [merchants, setMerchants] = useState<Option[]>([])
const [drivers, setDrivers] = useState<Option[]>([])
const [cities, setCities] = useState<Option[]>([])
const [regions, setRegions] = useState<Option[]>([])
const [form, setForm] = useState<FormState>(emptyForm)
const [filters, setFilters] = useState({ query: '', status: '' })
const [nextStatus, setNextStatus] = useState('')
const [statusDriver, setStatusDriver] = useState('')
const [statusComment, setStatusComment] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [notice, setNotice] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const fetchOrders = useCallback(async () => {
const params: Record<string, string | number> = { page: 0, limit: 50, field: 'createdAt', sort: 'desc' }
if (filters.status) params.current_status = filters.status
if (filters.query) {
if (/^[0-9+\-\s]+$/.test(filters.query)) {
params.customer_phone = filters.query
} else {
params.order_code = filters.query
}
}
const { data } = await axios.get('orders', { params })
setOrders(Array.isArray(data.rows) ? data.rows : [])
}, [filters.query, filters.status])
const fetchLookups = useCallback(async () => {
const [merchantRes, driverRes, cityRes, regionRes] = await Promise.all([
axios.get('merchants', { params: { page: 0, limit: 100 } }),
axios.get('drivers', { params: { page: 0, limit: 100 } }),
axios.get('cities', { params: { page: 0, limit: 100 } }),
axios.get('regions', { params: { page: 0, limit: 100 } }),
])
setMerchants(merchantRes.data.rows || [])
setDrivers(driverRes.data.rows || [])
setCities(cityRes.data.rows || [])
setRegions(regionRes.data.rows || [])
}, [])
const refresh = useCallback(async () => {
setLoading(true)
try {
await Promise.all([fetchOrders(), fetchLookups()])
} catch (error) {
console.error('Failed to load parcel operations data', error)
setNotice({ type: 'error', text: 'تعذر تحميل بيانات العمليات. تحقق من الصلاحيات أو الاتصال.' })
} finally {
setLoading(false)
}
}, [fetchLookups, fetchOrders])
useEffect(() => {
if (!currentUser?.id) {
setLoading(false)
return undefined
}
refresh().catch((error) => {
console.error('Initial operations refresh failed', error)
})
const timer = window.setInterval(() => {
fetchOrders().catch((error) => {
console.error('Auto refresh failed', error)
})
}, 30000)
return () => window.clearInterval(timer)
}, [currentUser?.id, fetchOrders, refresh])
const selectedAllowedStatuses = useMemo(() => {
if (!selectedOrder) return []
return nextStatuses[selectedOrder.current_status || 'pending'] || []
}, [selectedOrder])
const stats = useMemo(() => {
const today = todayIso()
const todaysOrders = orders.filter((order) => order.createdAt?.slice(0, 10) === today)
return [
{ label: 'طلبات اليوم', value: todaysOrders.length, hint: 'تحديث تلقائي كل 30 ثانية' },
{ label: 'قيد الانتظار', value: orders.filter((order) => order.current_status === 'pending').length, hint: 'تحتاج إجراء' },
{ label: 'تم التسليم', value: orders.filter((order) => order.current_status === 'delivered').length, hint: 'طلبات مكتملة' },
{ label: 'فشل التسليم', value: orders.filter((order) => order.current_status === 'failed_attempt').length, hint: 'تحتاج متابعة' },
]
}, [orders])
const revenueToday = useMemo(
() =>
orders
.filter((order) => order.createdAt?.slice(0, 10) === todayIso())
.reduce((sum, order) => sum + Number(order.cod_amount || 0), 0),
[orders],
)
const handleFormChange = (field: keyof FormState, value: string) => {
setForm((current) => ({ ...current, [field]: value }))
}
const handleCreateOrder = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!form.customer_name.trim() || !form.customer_phone.trim() || !form.delivery_address.trim()) {
setNotice({ type: 'error', text: 'اسم العميل، الهاتف، والعنوان حقول مطلوبة.' })
return
}
setSaving(true)
setNotice(null)
try {
const orderCode = `ORD-${Date.now().toString().slice(-8)}`
await axios.post('orders', {
data: {
...form,
order_code: orderCode,
current_status: 'pending',
previous_status: 'pending',
placed_at: new Date().toISOString(),
last_status_at: new Date().toISOString(),
merchant: form.merchant || null,
delivery_city: form.delivery_city || null,
delivery_region: form.delivery_region || null,
cod_amount: form.cod_amount || 0,
},
})
setForm(emptyForm)
await fetchOrders()
setNotice({ type: 'success', text: `تم إنشاء الطلب ${orderCode} وأصبح جاهزاً للتعيين.` })
} catch (error) {
console.error('Create order failed', error)
setNotice({ type: 'error', text: 'تعذر إنشاء الطلب. تأكد من البيانات والصلاحيات.' })
} finally {
setSaving(false)
}
}
const openOrder = async (order: Order) => {
setSelectedOrder(order)
setNextStatus('')
setStatusDriver(order.assigned_driver?.id || '')
setStatusComment('')
try {
const { data } = await axios.get(`orders/${order.id}`)
setSelectedOrder(data)
setStatusDriver(data?.assigned_driver?.id || '')
} catch (error) {
console.error('Load order detail failed', error)
setNotice({ type: 'error', text: 'تعذر فتح تفاصيل الطلب.' })
}
}
const handleStatusChange = async () => {
if (!selectedOrder || !nextStatus) {
setNotice({ type: 'error', text: 'اختر الحالة التالية أولاً.' })
return
}
if (nextStatus === 'assigned' && !statusDriver) {
setNotice({ type: 'error', text: 'يجب اختيار سائق قبل تحويل الطلب إلى تم التعيين.' })
return
}
setSaving(true)
setNotice(null)
try {
const { data } = await axios.put(`orders/${selectedOrder.id}/status`, {
data: {
current_status: nextStatus,
assigned_driver: statusDriver || undefined,
comment: statusComment,
},
})
setSelectedOrder(data)
setNextStatus('')
setStatusComment('')
await fetchOrders()
setNotice({ type: 'success', text: 'تم تحديث حالة الطلب وتسجيلها في السجل.' })
} catch (error) {
console.error('Status update failed', error)
setNotice({ type: 'error', text: 'رفض الخادم هذا الانتقال. تأكد من التسلسل والصلاحيات.' })
} finally {
setSaving(false)
}
}
const sortedLogs = [...(selectedOrder?.order_status_logs_order || [])].sort(
(a, b) => new Date(b.changed_at || b.createdAt || '').getTime() - new Date(a.changed_at || a.createdAt || '').getTime(),
)
return (
<>
<Head>
<title>{getPageTitle('Parcel Operations')}</title>
</Head>
<SectionMain>
<div dir="rtl" className="min-h-screen space-y-6 text-slate-900" style={{ fontFamily: 'Cairo, ui-sans-serif, system-ui' }}>
<section className="overflow-hidden rounded-[2rem] bg-gradient-to-br from-[#01696f] via-[#07575d] to-[#0f2730] p-6 text-white shadow-2xl shadow-teal-900/20 md:p-8">
<div className="grid gap-6 lg:grid-cols-[1.4fr_0.8fr]">
<div>
<div className="mb-5 inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
مركز عمليات الطرود · [اسم شركتك]
</div>
<h1 className="text-3xl font-extrabold leading-tight md:text-5xl">إدارة طلبات التوصيل من الإدخال حتى تغيير الحالة</h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-teal-50 md:text-base">
شريحة تشغيلية أولى تربط إنشاء الطلب، قائمة الطلبات، تفاصيل الطلب، وتحديث الحالة وفق مسار العمل المعتمد وبصلاحيات الخادم.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<BaseButton label="إنشاء طلب جديد" color="info" className="border-white/20 bg-white text-[#01696f] hover:bg-teal-50" onClick={() => document.getElementById('create-order-form')?.scrollIntoView({ behavior: 'smooth' })} />
<BaseButton label="العودة للوحة الإدارة" color="white" outline href="/dashboard" className="border-white/30 text-white hover:bg-white/10" />
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur">
<p className="text-sm text-teal-50">المستخدم الحالي</p>
<p className="mt-2 text-xl font-bold">{currentUser?.firstName || currentUser?.email || 'مشغل النظام'}</p>
<p className="text-sm text-teal-100">الدور: {currentUser?.app_role?.name || 'غير محدد'}</p>
<div className="mt-6 rounded-2xl bg-white p-4 text-slate-900">
<p className="text-sm text-slate-500">تحصيل اليوم COD</p>
<p className="mt-1 text-3xl font-extrabold" style={{ color: teal }}>{revenueToday.toLocaleString()} د.أ</p>
</div>
</div>
</div>
</section>
{notice && (
<div className={`rounded-2xl border px-4 py-3 text-sm font-semibold ${notice.type === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-red-200 bg-red-50 text-red-800'}`}>
{notice.text}
</div>
)}
<div className="grid gap-4 md:grid-cols-4">
{stats.map((stat) => (
<CardBox key={stat.label} className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
<p className="text-sm font-semibold text-slate-500">{stat.label}</p>
<p className="mt-2 text-3xl font-extrabold" style={{ color: teal }}>{loading ? '…' : stat.value}</p>
<p className="mt-2 text-xs text-slate-400">{stat.hint}</p>
</CardBox>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.35fr]">
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
<div id="create-order-form" className="mb-5 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-bold text-teal-700">طلب جديد</p>
<h2 className="text-2xl font-extrabold text-slate-900">إدخال طلب يدوي</h2>
</div>
<span className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">الحالة الافتراضية: قيد الانتظار</span>
</div>
<form className="grid gap-4" onSubmit={handleCreateOrder}>
<div className="grid gap-3 md:grid-cols-2">
<FieldLabel label="التاجر">
<select className="ops-input" value={form.merchant} onChange={(event) => handleFormChange('merchant', event.target.value)}>
<option value="">بدون تاجر</option>
{merchants.map((merchant) => <option key={merchant.id} value={merchant.id}>{optionLabel(merchant)}</option>)}
</select>
</FieldLabel>
<FieldLabel label="قيمة التحصيل COD">
<input className="ops-input" inputMode="decimal" value={form.cod_amount} onChange={(event) => handleFormChange('cod_amount', event.target.value)} placeholder="0.00" />
</FieldLabel>
</div>
<div className="grid gap-3 md:grid-cols-2">
<FieldLabel label="اسم العميل *">
<input className="ops-input" value={form.customer_name} onChange={(event) => handleFormChange('customer_name', event.target.value)} placeholder="مثال: أحمد محمد" />
</FieldLabel>
<FieldLabel label="هاتف العميل *">
<input className="ops-input" value={form.customer_phone} onChange={(event) => handleFormChange('customer_phone', event.target.value)} placeholder="07xxxxxxxx" />
</FieldLabel>
</div>
<div className="grid gap-3 md:grid-cols-2">
<FieldLabel label="المدينة">
<select className="ops-input" value={form.delivery_city} onChange={(event) => handleFormChange('delivery_city', event.target.value)}>
<option value="">اختر المدينة</option>
{cities.map((city) => <option key={city.id} value={city.id}>{optionLabel(city)}</option>)}
</select>
</FieldLabel>
<FieldLabel label="المنطقة">
<select className="ops-input" value={form.delivery_region} onChange={(event) => handleFormChange('delivery_region', event.target.value)}>
<option value="">اختر المنطقة</option>
{regions.map((region) => <option key={region.id} value={region.id}>{optionLabel(region)}</option>)}
</select>
</FieldLabel>
</div>
<FieldLabel label="العنوان الكامل *">
<textarea className="ops-input min-h-20" value={form.delivery_address} onChange={(event) => handleFormChange('delivery_address', event.target.value)} placeholder="الحي، الشارع، أقرب معلم" />
</FieldLabel>
<div className="grid gap-3 md:grid-cols-2">
<FieldLabel label="وصف الطرد">
<input className="ops-input" value={form.package_description} onChange={(event) => handleFormChange('package_description', event.target.value)} placeholder="ملابس، مستندات، إلكترونيات..." />
</FieldLabel>
<FieldLabel label="الوزن/الحجم">
<input className="ops-input" value={form.weight_size} onChange={(event) => handleFormChange('weight_size', event.target.value)} placeholder="اختياري" />
</FieldLabel>
</div>
<FieldLabel label="ملاحظات داخلية">
<input className="ops-input" value={form.notes} onChange={(event) => handleFormChange('notes', event.target.value)} placeholder="أي تعليمات خاصة للتسليم" />
</FieldLabel>
<BaseButton type="submit" color="info" label={saving ? 'جارٍ الحفظ...' : 'إنشاء الطلب'} disabled={saving} className="w-full border-[#01696f] bg-[#01696f] py-3 text-white hover:bg-[#07575d]" />
</form>
</CardBox>
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100" hasComponentLayout>
<div className="border-b border-slate-100 p-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-teal-700">قائمة التشغيل</p>
<h2 className="text-2xl font-extrabold">آخر 50 طلب</h2>
</div>
<div className="flex gap-2">
<input className="ops-input w-full md:w-56" value={filters.query} onChange={(event) => setFilters((current) => ({ ...current, query: event.target.value }))} placeholder="بحث برقم الطلب أو الهاتف" />
<select className="ops-input w-40" value={filters.status} onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}>
<option value="">كل الحالات</option>
{Object.entries(statusMeta).map(([value, meta]) => <option key={value} value={value}>{meta.label}</option>)}
</select>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-right text-sm">
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th className="px-4 py-3">رقم الطلب</th>
<th className="px-4 py-3">العميل</th>
<th className="px-4 py-3">التاجر</th>
<th className="px-4 py-3">السائق</th>
<th className="px-4 py-3">الحالة</th>
<th className="px-4 py-3">COD</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{orders.map((order) => (
<tr key={order.id} className="cursor-pointer transition hover:bg-teal-50/60" onClick={() => openOrder(order)}>
<td className="px-4 py-4 font-extrabold text-teal-700">{order.order_code || order.id.slice(0, 8)}</td>
<td className="px-4 py-4"><p className="font-bold">{order.customer_name || '—'}</p><p className="text-xs text-slate-400">{order.customer_phone || '—'}</p></td>
<td className="px-4 py-4 text-slate-600">{optionLabel(order.merchant)}</td>
<td className="px-4 py-4 text-slate-600">{optionLabel(order.assigned_driver)}</td>
<td className="px-4 py-4"><span className={badgeClass(order.current_status)}>{labelFor(order.current_status)}</span></td>
<td className="px-4 py-4 font-bold">{Number(order.cod_amount || 0).toLocaleString()}</td>
</tr>
))}
{!orders.length && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-slate-500">
لا توجد طلبات مطابقة حالياً. ابدأ بإنشاء أول طلب من النموذج المجاور.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardBox>
</div>
<CardBox className="border-0 bg-white shadow-sm ring-1 ring-slate-100">
{!selectedOrder ? (
<div className="rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-10 text-center">
<p className="text-xl font-extrabold">اختر طلباً من القائمة</p>
<p className="mt-2 text-sm text-slate-500">ستظهر هنا تفاصيل الطلب، الإجراءات التالية المسموحة، وسجل الحالات.</p>
</div>
) : (
<div className="grid gap-6 lg:grid-cols-[1fr_0.9fr]">
<div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-bold text-teal-700">تفاصيل الطلب</p>
<h2 className="text-3xl font-extrabold">{selectedOrder.order_code || selectedOrder.id.slice(0, 8)}</h2>
</div>
<span className={badgeClass(selectedOrder.current_status)}>{labelFor(selectedOrder.current_status)}</span>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<Info label="العميل" value={selectedOrder.customer_name} />
<Info label="الهاتف" value={selectedOrder.customer_phone} />
<Info label="التاجر" value={optionLabel(selectedOrder.merchant)} />
<Info label="السائق" value={optionLabel(selectedOrder.assigned_driver)} />
<Info label="المدينة" value={optionLabel(selectedOrder.delivery_city)} />
<Info label="المنطقة" value={optionLabel(selectedOrder.delivery_region)} />
<Info label="العنوان" value={selectedOrder.delivery_address} wide />
<Info label="وصف الطرد" value={selectedOrder.package_description} />
<Info label="COD" value={`${Number(selectedOrder.cod_amount || 0).toLocaleString()} د.أ`} />
</div>
</div>
<div className="rounded-3xl bg-slate-50 p-5">
<h3 className="text-xl font-extrabold">تغيير الحالة</h3>
<p className="mt-1 text-sm text-slate-500">الواجهة تعرض فقط الانتقالات التالية، والخادم يرفض أي انتقال غير مسموح.</p>
<div className="mt-4 grid gap-3">
<FieldLabel label="الحالة التالية">
<select className="ops-input bg-white" value={nextStatus} onChange={(event) => setNextStatus(event.target.value)}>
<option value="">اختر الإجراء التالي</option>
{selectedAllowedStatuses.map((status) => <option key={status} value={status}>{labelFor(status)}</option>)}
</select>
</FieldLabel>
{nextStatus === 'assigned' && (
<FieldLabel label="السائق المعيّن *">
<select className="ops-input bg-white" value={statusDriver} onChange={(event) => setStatusDriver(event.target.value)}>
<option value="">اختر السائق</option>
{drivers.map((driver) => <option key={driver.id} value={driver.id}>{optionLabel(driver)}</option>)}
</select>
</FieldLabel>
)}
<FieldLabel label="تعليق على الحركة">
<textarea className="ops-input min-h-20 bg-white" value={statusComment} onChange={(event) => setStatusComment(event.target.value)} placeholder="مثال: تم التواصل مع العميل وتأكيد الموعد" />
</FieldLabel>
<BaseButton color="info" label={saving ? 'جارٍ التحديث...' : 'تطبيق تغيير الحالة'} disabled={saving || !selectedAllowedStatuses.length} onClick={handleStatusChange} className="border-[#01696f] bg-[#01696f] py-3 text-white hover:bg-[#07575d]" />
{!selectedAllowedStatuses.length && <p className="text-sm font-semibold text-slate-500">هذه حالة نهائية ولا توجد انتقالات متاحة.</p>}
</div>
</div>
<div className="lg:col-span-2">
<h3 className="mb-4 text-xl font-extrabold">سجل الحالات</h3>
<div className="space-y-3">
{sortedLogs.map((log) => (
<div key={log.id} className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2 text-sm font-bold">
<span className={badgeClass(log.from_status)}>{labelFor(log.from_status)}</span>
<span className="text-slate-300"></span>
<span className={badgeClass(log.to_status)}>{labelFor(log.to_status)}</span>
<span className="mr-auto text-xs text-slate-400">{new Date(log.changed_at || log.createdAt || '').toLocaleString('ar')}</span>
</div>
{log.comment && <p className="mt-2 text-sm text-slate-600">{log.comment}</p>}
</div>
))}
{!sortedLogs.length && <p className="rounded-2xl bg-slate-50 p-5 text-center text-sm text-slate-500">لا توجد حركات مسجلة بعد. أول تغيير حالة سيظهر هنا.</p>}
</div>
</div>
</div>
)}
</CardBox>
</div>
<style jsx global>{`
.ops-input {
width: 100%;
border-radius: 1rem;
border: 1px solid rgb(226 232 240);
background: rgb(248 250 252);
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: rgb(15 23 42);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease;
}
.ops-input:focus {
border-color: #01696f;
background: white;
box-shadow: 0 0 0 3px rgba(1, 105, 111, 0.14);
}
`}</style>
</SectionMain>
</>
)
}
const FieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<label className="block">
<span className="mb-2 block text-sm font-extrabold text-slate-700">{label}</span>
{children}
</label>
)
const Info = ({ label, value, wide }: { label: string; value?: string | number; wide?: boolean }) => (
<div className={`rounded-2xl bg-slate-50 p-4 ${wide ? 'md:col-span-2' : ''}`}>
<p className="text-xs font-bold text-slate-400">{label}</p>
<p className="mt-1 font-extrabold text-slate-800">{value || '—'}</p>
</div>
)
OperationsOrdersPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default OperationsOrdersPage

View File

@ -28,18 +28,18 @@ export default function Register() {
const { data: response } = await axios.post('/auth/signup',value);
await router.push('/login')
setLoading(false)
notify('success', 'Please check your email for verification link')
notify('success', 'تحقق من بريدك الإلكتروني لتأكيد الحساب')
} catch (error) {
setLoading(false)
console.log('error: ', error)
notify('error', 'Something was wrong. Try again')
notify('error', 'حدث خطأ. حاول مرة أخرى')
}
};
return (
<>
<Head>
<title>{getPageTitle('Login')}</title>
<title>{getPageTitle('إنشاء حساب')}</title>
</Head>
<SectionFullScreen bg='violet'>
@ -54,13 +54,13 @@ export default function Register() {
>
<Form>
<FormField label='Email' help='Please enter your email'>
<FormField label='البريد الإلكتروني' help='الرجاء إدخال البريد الإلكتروني'>
<Field type='email' name='email' />
</FormField>
<FormField label='Password' help='Please enter your password'>
<FormField label='كلمة المرور' help='الرجاء إدخال كلمة المرور'>
<Field type='password' name='password' />
</FormField>
<FormField label='Confirm Password' help='Please confirm your password'>
<FormField label='تأكيد كلمة المرور' help='الرجاء تأكيد كلمة المرور'>
<Field type='password' name='confirm' />
</FormField>
@ -69,12 +69,12 @@ export default function Register() {
<BaseButtons>
<BaseButton
type='submit'
label={loading ? 'Loading...' : 'Register' }
label={loading ? 'جارٍ التحميل...' : 'إنشاء حساب' }
color='info'
/>
<BaseButton
href={'/login'}
label={'Login'}
label={'تسجيل الدخول'}
color='info'
/>
</BaseButtons>

View File

@ -28,12 +28,12 @@ export default function Verify() {
}).then(verified => {
if (verified) {
setLoading(false);
notify('success', 'Your email was verified');
notify('success', 'تم تأكيد البريد الإلكتروني بنجاح');
}
}).catch(error => {
setLoading(false);
console.log('error: ', error);
notify('error', error.response);
notify('error', 'تعذر تأكيد البريد الإلكتروني');
}).finally(async () => {
await router.push('/login');
});
@ -44,11 +44,11 @@ export default function Verify() {
return (
<>
<Head>
<title>{getPageTitle('Verify Email')}</title>
<title>{getPageTitle('تأكيد البريد الإلكتروني')}</title>
</Head>
<SectionFullScreen bg='violet'>
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
<p>{loading ? 'Loading...' : ''}</p>
<p>{loading ? 'جارٍ التحقق...' : ''}</p>
</CardBox>
</SectionFullScreen>

View File

@ -123,6 +123,23 @@ export const update = createAsyncThunk('orders/updateOrders', async (payload: an
})
export const changeStatus = createAsyncThunk('orders/changeStatus', async (payload: any, { rejectWithValue }) => {
try {
const result = await axios.put(
`orders/${payload.id}/status`,
{ data: payload.data }
)
return result.data
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
})
export const ordersSlice = createSlice({
name: 'orders',
initialState,
@ -208,6 +225,20 @@ export const ordersSlice = createSlice({
rejectNotify(state, action);
})
builder.addCase(changeStatus.pending, (state) => {
state.loading = true
resetNotify(state);
})
builder.addCase(changeStatus.fulfilled, (state, action) => {
state.loading = false
state.orders = action.payload
fulfilledNotify(state, 'Order status has been updated');
})
builder.addCase(changeStatus.rejected, (state, action) => {
state.loading = false
rejectNotify(state, action);
})
builder.addCase(uploadCsv.pending, (state) => {
state.loading = true;
resetNotify(state);