This commit is contained in:
hossein taromi 2025-09-20 11:30:25 +03:30
commit ce622057d4
17 changed files with 678 additions and 76 deletions

View File

@ -63,6 +63,10 @@ const ProductDetailPage = lazy(() => import('./pages/products/product-detail/Pro
// Landing Hero Page // Landing Hero Page
const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage')); const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage'));
// Shipping Methods Pages
const ShippingMethodsListPage = lazy(() => import('./pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage'));
const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipping-method-form/ShippingMethodFormPage'));
const ProtectedRoute = ({ children }: { children: any }) => { const ProtectedRoute = ({ children }: { children: any }) => {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@ -138,6 +142,11 @@ const AppRoutes = () => {
{/* Landing Hero Route */} {/* Landing Hero Route */}
<Route path="landing-hero" element={<HeroSliderPage />} /> <Route path="landing-hero" element={<HeroSliderPage />} />
{/* Shipping Methods Routes */}
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
{/* Products Routes */} {/* Products Routes */}
<Route path="products/create" element={<ProductFormPage />} /> <Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} /> <Route path="products/:id" element={<ProductDetailPage />} />

View File

@ -15,6 +15,7 @@ import {
BadgePercent, BadgePercent,
ShoppingCart, ShoppingCart,
Users, Users,
Truck,
X X
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
@ -98,6 +99,11 @@ const menuItems: MenuItem[] = [
icon: Sliders, icon: Sliders,
path: '/landing-hero', path: '/landing-hero',
}, },
{
title: 'روش‌های ارسال',
icon: Truck,
path: '/shipping-methods',
},
] ]
} }
]; ];

View File

@ -16,6 +16,10 @@ interface MultiSelectAutocompleteProps {
error?: string; error?: string;
isLoading?: boolean; isLoading?: boolean;
disabled?: boolean; disabled?: boolean;
onSearchChange?: (query: string) => void;
onLoadMore?: () => void;
hasMore?: boolean;
loadingMore?: boolean;
} }
export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = ({ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = ({
@ -27,17 +31,25 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
error, error,
isLoading = false, isLoading = false,
disabled = false, disabled = false,
onSearchChange,
onLoadMore,
hasMore = false,
loadingMore = false,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filteredOptions = options.filter(option => const filteredOptions = options.filter(option =>
option.title.toLowerCase().includes(searchTerm.toLowerCase()) || option.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase())) (option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase()))
); );
// If parent provides onSearchChange, assume server-side filtering and use options as-is
const displayedOptions = onSearchChange ? options : filteredOptions;
const selectedOptions = options.filter(option => selectedValues.includes(option.id)); const selectedOptions = options.filter(option => selectedValues.includes(option.id));
useEffect(() => { useEffect(() => {
@ -100,7 +112,7 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
key={option.id} key={option.id}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-xs rounded-md" className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-xs rounded-md"
> >
{option.title} {option.title || option.description || `#${option.id}`}
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@ -125,7 +137,11 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
ref={inputRef} ref={inputRef}
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => {
const value = e.target.value;
setSearchTerm(value);
if (onSearchChange) onSearchChange(value);
}}
className="w-full border-none outline-none bg-transparent text-sm" className="w-full border-none outline-none bg-transparent text-sm"
placeholder="جستجو..." placeholder="جستجو..."
/> />
@ -140,18 +156,28 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
{/* Dropdown */} {/* Dropdown */}
{isOpen && !disabled && ( {isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto"> <div
ref={listRef}
onScroll={() => {
const el = listRef.current;
if (!el || !onLoadMore || !hasMore || loadingMore) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
if (nearBottom) onLoadMore();
}}
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-60 overflow-auto"
>
{isLoading ? ( {isLoading ? (
<div className="p-3 text-center text-gray-500 dark:text-gray-400"> <div className="p-3 text-center text-gray-500 dark:text-gray-400">
در حال بارگذاری... در حال بارگذاری...
</div> </div>
) : filteredOptions.length > 0 ? ( ) : displayedOptions.length > 0 ? (
filteredOptions.map(option => ( <>
{displayedOptions.map(option => (
<div <div
key={option.id} key={option.id}
className={` className={`
px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700
${selectedValues.includes(option.id) ? 'bg-primary-50 dark:bg-primary-900/80' : ''} ${selectedValues.includes(option.id) ? 'bg-primary-200 dark:bg-primary-700/70' : ''}
`} `}
onClick={() => handleToggleOption(option.id)} onClick={() => handleToggleOption(option.id)}
> >
@ -171,7 +197,13 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
)} )}
</div> </div>
</div> </div>
)) ))}
{onLoadMore && hasMore && (
<div className="p-2 text-center text-xs text-gray-500 dark:text-gray-400">
{loadingMore ? 'در حال بارگذاری بیشتر...' : 'اسکرول برای مشاهده بیشتر'}
</div>
)}
</>
) : ( ) : (
<div className="p-3 text-center text-gray-500 dark:text-gray-400"> <div className="p-3 text-center text-gray-500 dark:text-gray-400">
موردی یافت نشد موردی یافت نشد

View File

@ -83,12 +83,19 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
<th <th
key={column.key} key={column.key}
className={clsx( className={clsx(
'px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider', 'px-6 py-3 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider',
column.align === 'left' && 'text-left',
column.align === 'center' && 'text-center',
(!column.align || column.align === 'right') && 'text-right',
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600' column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600'
)} )}
onClick={() => column.sortable && handleSort(column.key)} onClick={() => column.sortable && handleSort(column.key)}
> >
<div className="flex items-center justify-end space-x-1"> <div className={clsx('flex items-center space-x-1',
column.align === 'left' && 'justify-start',
column.align === 'center' && 'justify-center',
(!column.align || column.align === 'right') && 'justify-end'
)}>
<span>{column.label}</span> <span>{column.label}</span>
{column.sortable && ( {column.sortable && (
<div className="flex flex-col"> <div className="flex flex-col">
@ -119,7 +126,12 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
{sortedData.map((row, rowIndex) => ( {sortedData.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700"> <tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-700">
{columns.map((column) => ( {columns.map((column) => (
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 text-right"> <td key={column.key} className={clsx(
'px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100',
column.align === 'left' && 'text-left',
column.align === 'center' && 'text-center',
(!column.align || column.align === 'right') && 'text-right'
)}>
{column.render ? column.render(row[column.key], row) : row[column.key]} {column.render ? column.render(row[column.key], row) : row[column.key]}
</td> </td>
))} ))}

View File

@ -93,6 +93,16 @@ export const API_ROUTES = {
GET_ORDER: (id: string) => `checkout/orders/${id}`, GET_ORDER: (id: string) => `checkout/orders/${id}`,
UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`, UPDATE_ORDER_STATUS: (id: string) => `checkout/orders/${id}/status`,
// Shipping Methods APIs
GET_SHIPPING_METHODS: "api/v1/admin/checkout/shipping-methods",
GET_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
CREATE_SHIPPING_METHOD: "api/v1/admin/checkout/shipping-methods",
UPDATE_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
DELETE_SHIPPING_METHOD: (id: string) =>
`api/v1/admin/checkout/shipping-methods/${id}`,
// User Admin APIs // User Admin APIs
GET_USERS: "api/v1/admin/users", GET_USERS: "api/v1/admin/users",
GET_USER: (id: string) => `api/v1/admin/users/${id}`, GET_USER: (id: string) => `api/v1/admin/users/${id}`,

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
@ -9,8 +9,10 @@ import { CreateDiscountCodeRequest } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
import { FormHeader, PageContainer, Label, SectionTitle } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, Label, SectionTitle } from '../../../components/ui/Typography';
import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from 'lucide-react'; import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from 'lucide-react';
import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks';
const schema = yup.object({ const schema = yup.object({
code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'), code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'),
@ -58,15 +60,53 @@ const formatDateTimeLocal = (dateString?: string): string => {
} }
}; };
// Convert input value (YYYY-MM-DDTHH:mm) to API format (YYYY-MM-DDTHH:mm:00Z)
const toApiDateTime = (value?: string): string | undefined => {
if (!value) return undefined;
const trimmed = value.slice(0, 16);
return `${trimmed}:00Z`;
};
const DiscountCodeFormPage = () => { const DiscountCodeFormPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const isEdit = !!id; const isEdit = !!id;
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const { data: dc, isLoading: dcLoading } = useDiscountCode(id || ''); const { data: dc, isLoading: dcLoading } = useDiscountCode(id || '');
const { mutate: create, isPending: creating } = useCreateDiscountCode(); const { mutate: create, isPending: creating } = useCreateDiscountCode();
const { mutate: update, isPending: updating } = useUpdateDiscountCode(); const { mutate: update, isPending: updating } = useUpdateDiscountCode();
// Users list with pagination and search for dropdown
const [userSearch, setUserSearch] = useState<string>('');
const [userOffset, setUserOffset] = useState<number>(0);
const [accumulatedUsers, setAccumulatedUsers] = useState<any[]>([]);
const USERS_PAGE_SIZE = 20;
const { data: searchResult, isLoading: usersLoading } = useSearchUsers({ limit: USERS_PAGE_SIZE, offset: userOffset, search_text: userSearch });
useEffect(() => {
if (searchResult?.users) {
setAccumulatedUsers(prev => {
// If offset is 0 (new search), replace; otherwise append unique
if (userOffset === 0) return searchResult.users;
const byId = new Map(prev.map((u: any) => [u.id, u]));
for (const u of searchResult.users) byId.set(u.id, u);
return Array.from(byId.values());
});
}
}, [searchResult, userOffset]);
// Convert users to options
const userOptions: Option[] = (accumulatedUsers || []).map((user: any) => ({
id: user.id,
title: (user.first_name || user.last_name)
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: user.phone_number,
description: user.phone_number
}));
const { register, handleSubmit, formState: { errors, isValid }, reset } = useForm<CreateDiscountCodeRequest>({ const { register, handleSubmit, formState: { errors, isValid }, reset } = useForm<CreateDiscountCodeRequest>({
resolver: yupResolver(schema), resolver: yupResolver(schema),
mode: 'onChange', mode: 'onChange',
@ -93,14 +133,32 @@ const DiscountCodeFormPage = () => {
user_restrictions: dc.user_restrictions, user_restrictions: dc.user_restrictions,
meta: dc.meta, meta: dc.meta,
}); });
// Set selected user IDs
if (dc.user_restrictions?.user_ids) {
setSelectedUserIds(dc.user_restrictions.user_ids);
}
} }
}, [isEdit, dc, reset]); }, [isEdit, dc, reset]);
const onSubmit = (data: CreateDiscountCodeRequest) => { const onSubmit = (data: CreateDiscountCodeRequest) => {
// Clean user_restrictions: remove new_users_only and loyal_users_only, and normalize dates
const { new_users_only, loyal_users_only, ...cleanRestrictions } = (data.user_restrictions || {}) as any;
const formData: CreateDiscountCodeRequest = {
...data,
valid_from: toApiDateTime(data.valid_from),
valid_to: toApiDateTime(data.valid_to),
user_restrictions: {
...cleanRestrictions,
user_ids: selectedUserIds.length > 0 ? selectedUserIds : undefined,
},
};
if (isEdit && id) { if (isEdit && id) {
update({ id: parseInt(id), ...data }, { onSuccess: () => navigate('/discount-codes') }); update({ id: parseInt(id), ...formData }, { onSuccess: () => navigate('/discount-codes') });
} else { } else {
create(data, { onSuccess: () => navigate('/discount-codes') }); create(formData, { onSuccess: () => navigate('/discount-codes') });
} }
}; };
@ -199,7 +257,7 @@ const DiscountCodeFormPage = () => {
label="مقدار تخفیف" label="مقدار تخفیف"
type="number" type="number"
step="0.01" step="0.01"
placeholder="20" placeholder="300000"
error={errors.value?.message as string} error={errors.value?.message as string}
thousandSeparator thousandSeparator
numeric numeric
@ -316,7 +374,7 @@ const DiscountCodeFormPage = () => {
</div> </div>
{/* محدودیت‌های کاربری */} {/* محدودیت‌های کاربری */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-visible">
<div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="bg-gradient-to-r from-orange-50 to-red-50 dark:from-gray-700 dark:to-gray-600 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg"> <div className="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg">
@ -326,35 +384,43 @@ const DiscountCodeFormPage = () => {
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="space-y-6">
<Input <div className="grid grid-cols-1 gap-6">
label="گروه کاربری" <div className="space-y-2">
type="text" <Label>گروه کاربری</Label>
placeholder="مثال: loyal" <select
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors"
{...register('user_restrictions.user_group')} {...register('user_restrictions.user_group')}
/> >
<div className="space-y-4"> <option value="loyal">وفادار (loyal)</option>
<Label>محدودیتهای خاص</Label> <option value="new">کاربر جدید (new)</option>
<div className="space-y-3"> <option value="all">همه کاربران (all)</option>
<div className="flex items-center gap-3"> </select>
<input
id="new_users_only"
type="checkbox"
className="h-5 w-5 text-blue-600 border-2 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
{...register('user_restrictions.new_users_only')}
/>
<Label htmlFor="new_users_only" className="text-base">فقط کاربران جدید</Label>
</div>
<div className="flex items-center gap-3">
<input
id="loyal_users_only"
type="checkbox"
className="h-5 w-5 text-blue-600 border-2 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
{...register('user_restrictions.loyal_users_only')}
/>
<Label htmlFor="loyal_users_only" className="text-base">فقط کاربران وفادار</Label>
</div> </div>
</div> </div>
{/* User Selection */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<MultiSelectAutocomplete
label="انتخاب کاربران خاص"
options={userOptions}
selectedValues={selectedUserIds}
onChange={setSelectedUserIds}
placeholder="جستجو و انتخاب کاربران..."
isLoading={usersLoading && userOffset === 0}
disabled={false}
onSearchChange={(q) => { setUserSearch(q); setUserOffset(0); }}
onLoadMore={() => {
if (!usersLoading && searchResult && (searchResult.total > accumulatedUsers.length)) {
setUserOffset(prev => prev + USERS_PAGE_SIZE);
}
}}
hasMore={!!searchResult && accumulatedUsers.length < (searchResult.total || 0)}
loadingMore={usersLoading && userOffset > 0}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
در صورت انتخاب کاربران، کد تخفیف فقط برای آنها قابل استفاده خواهد بود.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -55,7 +55,7 @@ export const updateOrderStatus = async (
export const getOrderStats = async (): Promise<OrderStats> => { export const getOrderStats = async (): Promise<OrderStats> => {
try { try {
const ordersResponse = await getOrders({ limit: 1000 }); const ordersResponse = await getOrders({ limit: 20 });
const stats: OrderStats = { const stats: OrderStats = {
total_orders: ordersResponse.total, total_orders: ordersResponse.total,

View File

@ -0,0 +1,84 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import {
getShippingMethods,
getShippingMethod,
createShippingMethod,
updateShippingMethod,
deleteShippingMethod,
} from "./_requests";
import {
CreateShippingMethodRequest,
UpdateShippingMethodRequest,
} from "./_models";
export const useShippingMethods = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHODS],
queryFn: getShippingMethods,
});
};
export const useShippingMethod = (id: string) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHOD, id],
queryFn: () => getShippingMethod(id),
enabled: !!id,
});
};
export const useCreateShippingMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreateShippingMethodRequest) =>
createShippingMethod(payload),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHODS],
});
toast.success("روش ارسال با موفقیت ایجاد شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در ایجاد روش ارسال");
},
});
};
export const useUpdateShippingMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateShippingMethodRequest) =>
updateShippingMethod(payload.id.toString(), payload),
onSuccess: (data: any) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHODS],
});
if (data?.id) {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHOD, data.id.toString()],
});
}
toast.success("روش ارسال با موفقیت به‌روزرسانی شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در به‌روزرسانی روش ارسال");
},
});
};
export const useDeleteShippingMethod = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteShippingMethod(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_SHIPPING_METHODS],
});
toast.success("روش ارسال با موفقیت حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف روش ارسال");
},
});
};

View File

@ -0,0 +1,28 @@
export interface ShippingMethod {
id: number;
name: string;
description?: string;
code: string;
enabled: boolean;
cost: number;
max_weight: number;
min_weight: number;
priority: number;
created_at?: string;
updated_at?: string;
}
export interface PaginatedShippingMethodsResponse {
shipping_methods: ShippingMethod[];
total: number;
}
export type CreateShippingMethodRequest = Omit<
ShippingMethod,
"id" | "created_at" | "updated_at"
>;
export type UpdateShippingMethodRequest =
Partial<CreateShippingMethodRequest> & {
id: number;
};

View File

@ -0,0 +1,58 @@
import {
httpGetRequest,
httpPostRequest,
httpPutRequest,
httpDeleteRequest,
APIUrlGenerator,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
CreateShippingMethodRequest,
UpdateShippingMethodRequest,
PaginatedShippingMethodsResponse,
ShippingMethod,
} from "./_models";
export const getShippingMethods = async () => {
const response = await httpGetRequest<PaginatedShippingMethodsResponse>(
APIUrlGenerator(API_ROUTES.GET_SHIPPING_METHODS)
);
return response.data.shipping_methods || [];
};
export const getShippingMethod = async (id: string) => {
const response = await httpGetRequest<any>(
APIUrlGenerator(API_ROUTES.GET_SHIPPING_METHOD(id))
);
const payload = response.data as any;
// Support either plain object or wrapped { shipping_method: { ... } }
return (payload?.shipping_method || payload) as ShippingMethod;
};
export const createShippingMethod = async (
payload: CreateShippingMethodRequest
) => {
const response = await httpPostRequest<ShippingMethod>(
APIUrlGenerator(API_ROUTES.CREATE_SHIPPING_METHOD),
payload
);
return response.data;
};
export const updateShippingMethod = async (
id: string,
payload: UpdateShippingMethodRequest
) => {
const response = await httpPutRequest<ShippingMethod>(
APIUrlGenerator(API_ROUTES.UPDATE_SHIPPING_METHOD(id)),
payload
);
return response.data;
};
export const deleteShippingMethod = async (id: string) => {
const response = await httpDeleteRequest<{ message: string }>(
APIUrlGenerator(API_ROUTES.DELETE_SHIPPING_METHOD(id))
);
return response.data;
};

View File

@ -0,0 +1,142 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } from '../core/_hooks';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Truck } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
const ShippingMethodFormPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const { data, isLoading } = useShippingMethod(id || '');
const { mutate: create, isPending: creating } = useCreateShippingMethod();
const { mutate: update, isPending: updating } = useUpdateShippingMethod();
const [form, setForm] = React.useState({
name: '',
description: '',
code: '',
enabled: true,
cost: '',
max_weight: '',
min_weight: '',
priority: '',
});
useEffect(() => {
if (isEdit && data) {
setForm({
name: data.name || '',
description: data.description || '',
code: data.code || '',
enabled: data.enabled,
cost: formatWithThousands(data.cost ?? ''),
max_weight: formatWithThousands(data.max_weight ?? ''),
min_weight: formatWithThousands(data.min_weight ?? ''),
priority: formatWithThousands(data.priority ?? ''),
});
}
}, [isEdit, data]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value, type, checked } = e.target as any;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const payload = {
name: form.name,
description: form.description,
code: form.code,
enabled: form.enabled,
cost: parseFormattedNumber(form.cost) ?? 0,
max_weight: parseFormattedNumber(form.max_weight) ?? 0,
min_weight: parseFormattedNumber(form.min_weight) ?? 0,
priority: parseFormattedNumber(form.priority) ?? 0,
};
if (isEdit && id) {
update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') });
} else {
create(payload, { onSuccess: () => navigate('/shipping-methods') });
}
};
if (isEdit && isLoading) {
return (
<div className="min-h-[200px] flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Truck className="h-6 w-6" />
{isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">نام</label>
<Input name="name" value={form.name} onChange={handleChange} placeholder="مثلاً Standard Shipping" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
<Input name="code" value={form.code} onChange={handleChange} placeholder="مثلاً standard" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">هزینه</label>
<Input name="cost" value={form.cost} onChange={handleChange} thousandSeparator numeric />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">اولویت</label>
<Input name="priority" value={form.priority} onChange={handleChange} thousandSeparator numeric />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کمترین وزن</label>
<Input name="min_weight" value={form.min_weight} onChange={handleChange} thousandSeparator numeric />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">بیشترین وزن</label>
<Input name="max_weight" value={form.max_weight} onChange={handleChange} thousandSeparator numeric />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">توضیحات</label>
<textarea name="description" value={form.description} onChange={handleChange} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" rows={3} />
</div>
<div className="md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="enabled" checked={form.enabled} onChange={handleChange} className="rounded border-gray-300 dark:border-gray-600" />
فعال
</label>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => navigate('/shipping-methods')}>
انصراف
</Button>
<Button type="submit" loading={creating || updating}>
{isEdit ? 'ذخیره تغییرات' : 'ایجاد'}
</Button>
</div>
</form>
</div>
);
};
export default ShippingMethodFormPage;

View File

@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Settings, Plus, Edit3, Trash2, Truck } from 'lucide-react';
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
import { ShippingMethod } from '../core/_models';
const ShippingMethodsListPage = () => {
const navigate = useNavigate();
const { data: methods, isLoading, error } = useShippingMethods();
const { mutate: deleteMethod, isPending: isDeleting } = useDeleteShippingMethod();
const [deleteId, setDeleteId] = useState<string | null>(null);
const handleCreate = () => navigate('/shipping-methods/create');
const handleEdit = (id: number) => navigate(`/shipping-methods/${id}/edit`);
const handleDeleteConfirm = () => {
if (!deleteId) return;
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
};
if (error) {
return (
<div className="p-6">
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Truck className="h-6 w-6" />
مدیریت روشهای ارسال
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">تعریف و مدیریت روشهای ارسال سفارش</p>
</div>
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="روش ارسال جدید"
>
<Plus className="h-5 w-5" />
</button>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">هزینه</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(methods || []).map((m: ShippingMethod) => (
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.cost}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
<Edit3 className="h-4 w-4" />
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="حذف">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile */}
<div className="md:hidden p-4 space-y-4">
{(methods || []).map((m: ShippingMethod) => (
<div key={m.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} هزینه: {m.cost}</p>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">وزن: {m.min_weight}-{m.max_weight} اولویت: {m.priority}</div>
<div className="flex items-center gap-2">
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<Trash2 className="h-3 w-3" />
حذف
</button>
</div>
</div>
))}
</div>
</div>
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف روش ارسال">
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این روش ارسال اطمینان دارید؟</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
</div>
</div>
</Modal>
</div>
);
};
export default ShippingMethodsListPage;

View File

@ -26,6 +26,8 @@ export const getUsers = async (filters?: UserFilters): Promise<User[]> => {
if (filters?.limit) queryParams.limit = filters.limit; if (filters?.limit) queryParams.limit = filters.limit;
if (filters?.offset) queryParams.offset = filters.offset; if (filters?.offset) queryParams.offset = filters.offset;
if (filters?.search_text) queryParams.search_text = filters.search_text;
if (filters?.verified !== undefined) queryParams.verified = filters.verified;
const response = await httpGetRequest<PaginatedUsersResponse>( const response = await httpGetRequest<PaginatedUsersResponse>(
APIUrlGenerator(API_ROUTES.GET_USERS, queryParams) APIUrlGenerator(API_ROUTES.GET_USERS, queryParams)
@ -136,7 +138,7 @@ export const unverifyUser = async (id: string): Promise<UserActionResponse> => {
// Get user statistics // Get user statistics
export const getUserStats = async (): Promise<UserStats> => { export const getUserStats = async (): Promise<UserStats> => {
const allUsers = await getUsers({ limit: 1000 }); const allUsers = await getUsers({ limit: 20 });
const stats: UserStats = { const stats: UserStats = {
total_users: allUsers.length, total_users: allUsers.length,

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup'; import * as yup from 'yup';
import { User, ArrowLeft, Save, UserPlus } from 'lucide-react'; import { User, ArrowLeft, Save, UserPlus } from 'lucide-react';
@ -75,7 +75,7 @@ const UserAdminFormPage: React.FC = () => {
const isEdit = !!id; const isEdit = !!id;
// Hooks // Hooks
const { data: user, isLoading: userLoading } = useUser(id!, { enabled: isEdit }); const { data: user, isLoading: userLoading } = useUser(id || '');
const createUserMutation = useCreateUser(); const createUserMutation = useCreateUser();
const updateUserMutation = useUpdateUser(); const updateUserMutation = useUpdateUser();
@ -96,13 +96,15 @@ const UserAdminFormPage: React.FC = () => {
// Populate form in edit mode // Populate form in edit mode
useEffect(() => { useEffect(() => {
if (isEdit && user) { if (isEdit && user) {
setValue('first_name', user.first_name); reset({
setValue('last_name', user.last_name); first_name: user.first_name,
setValue('email', user.email || ''); last_name: user.last_name,
setValue('national_code', user.national_code || ''); email: user.email || '',
setValue('verified', user.verified); national_code: user.national_code || '',
verified: user.verified,
} as any);
} }
}, [isEdit, user, setValue]); }, [isEdit, user, reset]);
// Handlers // Handlers
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Users, Plus, Search, Filter, UserCheck, UserX, Edit, Trash2, Eye } from 'lucide-react'; import { Users, Plus, Search, Filter, UserCheck, UserX, Edit, Trash2, Eye, User as UserIcon } from 'lucide-react';
import { useUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks'; import { useUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { User, UserFilters } from '../core/_models'; import { User, UserFilters } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography'; import { PageContainer } from '../../../components/ui/Typography';
@ -104,6 +104,7 @@ const UsersAdminListPage: React.FC = () => {
{ {
key: 'name', key: 'name',
label: 'کاربر', label: 'کاربر',
align: 'left',
render: (_val, row: any) => ( render: (_val, row: any) => (
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10"> <div className="flex-shrink-0 h-10 w-10">
@ -111,9 +112,13 @@ const UsersAdminListPage: React.FC = () => {
<img className="h-10 w-10 rounded-full object-cover" src={row.avatar} alt={`${row.first_name} ${row.last_name}`} /> <img className="h-10 w-10 rounded-full object-cover" src={row.avatar} alt={`${row.first_name} ${row.last_name}`} />
) : ( ) : (
<div className="h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center"> <div className="h-10 w-10 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
{row.first_name || row.last_name ? (
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"> <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{row.first_name?.charAt(0)}{row.last_name?.charAt(0)} {row.first_name?.charAt(0)}{row.last_name?.charAt(0)}
</span> </span>
) : (
<UserIcon className="h-5 w-5 text-gray-600 dark:text-gray-300" />
)}
</div> </div>
)} )}
</div> </div>
@ -128,11 +133,12 @@ const UsersAdminListPage: React.FC = () => {
</div> </div>
) )
}, },
{ key: 'phone_number', label: 'شماره تلفن' }, { key: 'phone_number', label: 'شماره تلفن', align: 'left' },
{ key: 'email', label: 'ایمیل', render: (v: string) => v || '-' }, { key: 'email', label: 'ایمیل', align: 'left', render: (v: string) => v || '-' },
{ {
key: 'verified', key: 'verified',
label: 'وضعیت', label: 'وضعیت',
align: 'center',
render: (v: boolean) => ( render: (v: boolean) => (
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${v <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${v
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
@ -145,6 +151,7 @@ const UsersAdminListPage: React.FC = () => {
{ {
key: 'actions', key: 'actions',
label: 'عملیات', label: 'عملیات',
align: 'center',
render: (_val, row: any) => ( render: (_val, row: any) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button

View File

@ -76,6 +76,7 @@ export interface TableColumn {
label: string; label: string;
sortable?: boolean; sortable?: boolean;
render?: (value: any, row: any) => any; render?: (value: any, row: any) => any;
align?: "left" | "right" | "center";
} }
export interface TableData { export interface TableData {

View File

@ -84,6 +84,13 @@ export const QUERY_KEYS = {
GET_ORDER: "get_order", GET_ORDER: "get_order",
UPDATE_ORDER_STATUS: "update_order_status", UPDATE_ORDER_STATUS: "update_order_status",
// Shipping Methods
GET_SHIPPING_METHODS: "get_shipping_methods",
GET_SHIPPING_METHOD: "get_shipping_method",
CREATE_SHIPPING_METHOD: "create_shipping_method",
UPDATE_SHIPPING_METHOD: "update_shipping_method",
DELETE_SHIPPING_METHOD: "delete_shipping_method",
// User Admin // User Admin
GET_USERS: "get_users", GET_USERS: "get_users",
GET_USER: "get_user", GET_USER: "get_user",