Merge branch 'main' of https://github.com/mazane-front/backoffice
This commit is contained in:
commit
ce622057d4
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,38 +156,54 @@ 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 => (
|
<>
|
||||||
<div
|
{displayedOptions.map(option => (
|
||||||
key={option.id}
|
<div
|
||||||
className={`
|
key={option.id}
|
||||||
|
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)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
{option.title}
|
{option.title}
|
||||||
</div>
|
|
||||||
{option.description && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{option.description}
|
|
||||||
</div>
|
</div>
|
||||||
|
{option.description && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{option.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedValues.includes(option.id) && (
|
||||||
|
<div className="text-primary-600 dark:text-primary-400">✓</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedValues.includes(option.id) && (
|
|
||||||
<div className="text-primary-600 dark:text-primary-400">✓</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">
|
||||||
موردی یافت نشد
|
موردی یافت نشد
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
|
|
|
||||||
|
|
@ -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,36 +384,44 @@ 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
|
||||||
{...register('user_restrictions.user_group')}
|
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')}
|
||||||
<div className="space-y-4">
|
>
|
||||||
<Label>محدودیتهای خاص</Label>
|
<option value="loyal">وفادار (loyal)</option>
|
||||||
<div className="space-y-3">
|
<option value="new">کاربر جدید (new)</option>
|
||||||
<div className="flex items-center gap-3">
|
<option value="all">همه کاربران (all)</option>
|
||||||
<input
|
</select>
|
||||||
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>
|
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 || "خطا در حذف روش ارسال");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
{row.first_name || row.last_name ? (
|
||||||
{row.first_name?.charAt(0)}{row.last_name?.charAt(0)}
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
</span>
|
{row.first_name?.charAt(0)}{row.last_name?.charAt(0)}
|
||||||
|
</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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue