DEAD_CODE_CLEAN #6
100 changed files with 23509 additions and 74 deletions
|
|
@ -30,6 +30,7 @@
|
|||
- Prefers detailed technical documentation of system architecture, data models, flows, and integrations in markdown format. Confidence: 0.8
|
||||
- Wants edge cases explicitly enumerated when documenting or analyzing existing code/systems. Confidence: 0.8
|
||||
- Values end-to-end analysis of features (architecture, data model, flows, integrations, and edge cases) when asked to explain how something works. Confidence: 0.7
|
||||
- When creating a clone/mirror of an app on a different platform (e.g., a web version of a React Native app), expects EXACT replication — functionality and looks must match the reference app with zero changes ("not even a slight change is acceptable"). The tech stack is inherited from the existing clone, not the original. Confidence: 0.9
|
||||
- Prefers unifying parallel data structures into a single instance rather than maintaining separate ones (e.g., one cart for flash and regular items instead of separate flash/regular carts). Confidence: 0.9
|
||||
- Prefers using sentinel values in existing fields (e.g., slotId = 0) to distinguish special-case items rather than creating separate fields or structures. Confidence: 0.9
|
||||
- Prefers handling special-case logic locally in the relevant component/file rather than globally or via parallel flows. Confidence: 0.85
|
||||
|
|
|
|||
19
apps/admin-web/.cta.json
Normal file
19
apps/admin-web/.cta.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"projectName": "web-ui",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": true,
|
||||
"packageManager": "bun",
|
||||
"git": true,
|
||||
"install": true,
|
||||
"intent": true,
|
||||
"addOnOptions": {},
|
||||
"includeExamples": false,
|
||||
"envVarValues": {},
|
||||
"routerOnly": false,
|
||||
"version": 1,
|
||||
"framework": "react",
|
||||
"chosenAddOns": [
|
||||
"nitro"
|
||||
]
|
||||
}
|
||||
15
apps/admin-web/README.md
Normal file
15
apps/admin-web/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Freshyo Admin Web
|
||||
|
||||
Web clone of `apps/admin-ui` — same screens, same functionality, same looks —
|
||||
built on the exact stack of `apps/web-ui` (TanStack Start + Vite + Tailwind v4 +
|
||||
tRPC + TanStack Router).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:4175
|
||||
npm run build # production build
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
Backend: same as admin-ui (`https://devapi.freshyo.in`); override with
|
||||
`VITE_API_URL` env var.
|
||||
9
apps/admin-web/change-log.txt
Normal file
9
apps/admin-web/change-log.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
[2026-09-06 00:10:00] admin-web integration fixes batch: (1) dashboard.notifications.tsx SearchableSelect(number[] mismatch) -> MultiSelect (matches original multi BottomDropdown); (2) TagMenu.tsx router.history.push string nav -> typed navigate({to search}); (3) dashboard.orders.$id.tsx user tap /dashboard/users -> /dashboard/users/$id params nav (route exists); (4) dashboard.users.$id.tsx order nav as-any -> typed params nav; (5) remove temp as-any on now-registered navigate literals (dashboard.index, orders hub, slots, banners, stores, prices, products.edit/groupings casts if tsc-clean); (6) regen routeTree.gen.ts via build.
|
||||
|
||||
[2026-09-06 00:20:00] admin-web README: replaced TanStack boilerplate with Freshyo Admin Web run/build notes (npm install/dev/build, port 4175, VITE_API_URL override).
|
||||
[2026-09-06 00:20:00] COMPLETED apps/admin-web: exact web clone of apps/admin-ui on apps/web-ui stack (TanStack Start + Vite + Tailwind v4 + tRPC + TanStack Router, port 4175, worker admin-web). 38 routes + 21 components + infra, tsc clean, vite build clean, SSR smoke 200s.
|
||||
Routes: index gate, login, dashboard shell (sidebar/header/titles/refresh) + hub, complaints, coupons(+new,+$id/edit), customize-app(+popular,+ordering), banners(+new,+$id/edit), orders(+list,+sequence,+$id), prices, product-groupings(+new,+$id/edit), product-tags(+new,+edit?tagId,+order), products(+new,+edit?id,+$id), rebalance, notifications, slots(+new?baseslot,+$id/edit,+detail?slotId), stores(+new,+edit?id), users(+$id), vendor-snippets.
|
||||
New local primitives: MultiSelect, SearchableSelect, DateTimeInput, DateInput (mirror RN BottomDropdown-multi/search + DateTimePickerMod/DatePicker APIs).
|
||||
Verification: tsc 0 errors; 54/54 file pairs tRPC-procedure parity (2 apparent gaps proven dead code in original: updateAddressCoords declaration, refund commented block); build ok; dev SSR / /login /dashboard /dashboard/coupons 200, /dashboard/orders/sequence 307 redirect, 0 error refs.
|
||||
Known platform deltas (no web equivalent): Alert.alert->window.alert, destructive confirms->window.confirm, pull-to-refresh->header refresh button, infinite scroll->Load-more buttons, expo-image-picker->file inputs, native date/time pickers->date+time inputs, haptics/blur/share-target->dropped or navigator.share w/ clipboard fallback, push-notification token acquisition dropped (send flow intact).
|
||||
6596
apps/admin-web/package-lock.json
generated
Normal file
6596
apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
60
apps/admin-web/package.json
Normal file
60
apps/admin-web/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "admin-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "npm run build && wrangler dev",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.10.2",
|
||||
"@tanstack/react-query": "^5.100.0",
|
||||
"@tanstack/react-query-devtools": "^5.100.0",
|
||||
"@tanstack/react-router": "^1.167.0",
|
||||
"@tanstack/react-router-devtools": "^1.166.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.0",
|
||||
"@tanstack/react-start": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.167.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/react-query": "^11.17.0",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"date-fns": "^4.1.0",
|
||||
"formik": "^2.4.9",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"nitro": "npm:nitro-nightly@latest",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"yup": "^1.7.1",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.36.3",
|
||||
"@tanstack/devtools-vite": "^0.6.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.5",
|
||||
"wrangler": "^4.90.0"
|
||||
}
|
||||
}
|
||||
BIN
apps/admin-web/public/favicon.png
Normal file
BIN
apps/admin-web/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/admin-web/public/freshyo-logo.png
Normal file
BIN
apps/admin-web/public/freshyo-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/admin-web/public/logo192.png
Normal file
BIN
apps/admin-web/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/admin-web/public/logo512.png
Normal file
BIN
apps/admin-web/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
25
apps/admin-web/public/manifest.json
Normal file
25
apps/admin-web/public/manifest.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "Freshyo",
|
||||
"name": "Freshyo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0033CC",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
apps/admin-web/public/robots.txt
Normal file
3
apps/admin-web/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
4
apps/admin-web/src/client.tsx
Normal file
4
apps/admin-web/src/client.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { StartClient } from '@tanstack/react-start/client'
|
||||
|
||||
hydrateRoot(document, <StartClient />)
|
||||
225
apps/admin-web/src/components/BannerForm.tsx
Normal file
225
apps/admin-web/src/components/BannerForm.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { useRef, useState, type ChangeEvent } from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { p as P, pInput as PInput, ImageUploader } from 'web-components'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
|
||||
export interface BannerFormData {
|
||||
name: string
|
||||
imageUrl: string
|
||||
description: string
|
||||
skuIds: number[]
|
||||
redirectUrl: string
|
||||
// serialNum removed - will be assigned automatically by backend
|
||||
}
|
||||
|
||||
interface BannerFormProps {
|
||||
initialValues: BannerFormData
|
||||
onSubmit: (values: BannerFormData, imageUrl?: string) => Promise<void> | void
|
||||
onCancel: () => void
|
||||
submitButtonText?: string
|
||||
isSubmitting?: boolean
|
||||
existingImageUrl?: string
|
||||
mode?: 'create' | 'edit'
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().trim().required('Banner name is required').max(255),
|
||||
description: Yup.string().max(500),
|
||||
skuIds: Yup.array()
|
||||
.of(Yup.number())
|
||||
.optional(),
|
||||
redirectUrl: Yup.string()
|
||||
.url('Please enter a valid URL')
|
||||
.optional(),
|
||||
// serialNum validation removed - assigned automatically by backend
|
||||
})
|
||||
|
||||
export default function BannerForm({
|
||||
initialValues,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitButtonText = 'Create Banner',
|
||||
isSubmitting = false,
|
||||
existingImageUrl,
|
||||
mode = 'create',
|
||||
}: BannerFormProps) {
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([])
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { uploadSingle } = useUploadToObjectStorage()
|
||||
|
||||
// Fetch products for dropdown
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
|
||||
const products = productsData?.products || []
|
||||
|
||||
const handleImagePick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
setSelectedImages([])
|
||||
setDisplayImages([])
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
setSelectedImages([{ blob: file, mimeType: file.type || 'image/jpeg' }])
|
||||
setDisplayImages([{ uri: URL.createObjectURL(file) }])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleRemoveImage = (uri: string) => {
|
||||
const index = displayImages.findIndex(img => img.uri === uri)
|
||||
if (index !== -1) {
|
||||
const newDisplay = displayImages.filter((_, i) => i !== index)
|
||||
const newFiles = selectedImages.filter((_, i) => i !== index)
|
||||
setDisplayImages(newDisplay)
|
||||
setSelectedImages(newFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormikSubmit = async (values: BannerFormData) => {
|
||||
try {
|
||||
let imageUrl: string | undefined
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
const { blob, mimeType } = selectedImages[0]
|
||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store')
|
||||
imageUrl = presignedUrl
|
||||
}
|
||||
|
||||
await onSubmit(values, imageUrl)
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
window.alert('Error: Failed to upload image')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={handleFormikSubmit}
|
||||
>
|
||||
{({
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
dirty,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex-1 overflow-auto px-6 py-6">
|
||||
<PInput
|
||||
topLabel="Banner Name"
|
||||
placeholder="Enter banner name"
|
||||
value={values.name}
|
||||
onChange={handleChange('name')}
|
||||
onBlur={handleBlur('name')}
|
||||
style={{ marginBottom: errors.name && touched.name ? 8 : 16 }}
|
||||
/>
|
||||
{errors.name && touched.name && (
|
||||
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.name}</P>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<P className="text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider">Banner Image</P>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={existingImageUrl ? [existingImageUrl] : []}
|
||||
onAddImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() => {
|
||||
// Handle removing existing image in edit mode
|
||||
}}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PInput
|
||||
topLabel="Description"
|
||||
placeholder="Enter banner description (optional)"
|
||||
value={values.description}
|
||||
onChange={handleChange('description')}
|
||||
onBlur={handleBlur('description')}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
style={{ marginBottom: errors.description && touched.description ? 8 : 16 }}
|
||||
/>
|
||||
{errors.description && touched.description && (
|
||||
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.description}</P>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<ProductsSelector
|
||||
value={values.skuIds}
|
||||
onChange={(value) => {
|
||||
const selectedValues = Array.isArray(value) ? value : [value]
|
||||
setFieldValue('skuIds', selectedValues.map(v => Number(v)))
|
||||
}}
|
||||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products for banner (optional)"
|
||||
labelFormat={(product) => `${product.productName} (₹${product.price})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PInput
|
||||
topLabel="Redirect URL (Optional)"
|
||||
placeholder="https://example.com/redirect"
|
||||
value={values.redirectUrl}
|
||||
onChange={handleChange('redirectUrl')}
|
||||
onBlur={handleBlur('redirectUrl')}
|
||||
type="url"
|
||||
style={{ marginBottom: errors.redirectUrl && touched.redirectUrl ? 8 : 16 }}
|
||||
/>
|
||||
{errors.redirectUrl && touched.redirectUrl && (
|
||||
<P className="text-red-500 text-xs mb-2 -mt-2">{errors.redirectUrl}</P>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-row gap-4 mb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 bg-gray-100 rounded-lg py-4 items-center text-gray-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isSubmitting || !isValid || !dirty}
|
||||
className={`flex-1 rounded-lg py-4 items-center text-white font-semibold disabled:opacity-70 ${
|
||||
isSubmitting || !isValid || !dirty
|
||||
? 'bg-blue-400'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : submitButtonText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
85
apps/admin-web/src/components/CancelOrderDialog.tsx
Normal file
85
apps/admin-web/src/components/CancelOrderDialog.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useState } from 'react'
|
||||
import type { OrderDialogBaseProps } from '@packages/shared'
|
||||
import { p as P, BottomDialog, pInput as PInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { XCircle } from 'lucide-react'
|
||||
|
||||
interface CancelOrderDialogProps extends OrderDialogBaseProps {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export default function CancelOrderDialog({ orderId, open, onClose, onSuccess }: CancelOrderDialogProps) {
|
||||
const [cancelReason, setCancelReason] = useState('')
|
||||
const cancelOrderMutation = trpc.admin.order.cancelOrder.useMutation()
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!cancelReason.trim()) {
|
||||
window.alert('Error: Please enter a cancellation reason')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Cancel Order? Are you sure you want to cancel this order? This action cannot be undone.'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
cancelOrderMutation.mutate(
|
||||
{ orderId, reason: cancelReason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
setCancelReason('')
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Error: ${error.message || 'Failed to cancel order'}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomDialog open={open} onClose={onClose}>
|
||||
<div className="p-6">
|
||||
<div className="items-center mb-6 flex flex-col">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-full items-center justify-center mb-3 flex">
|
||||
<XCircle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<P className="text-xl font-bold text-gray-900 text-center">
|
||||
Cancel Order
|
||||
</P>
|
||||
<P className="text-gray-500 text-center mt-2 text-sm leading-5">
|
||||
This will cancel the order and mark it as cancelled by admin. A refund record will be created.
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<PInput
|
||||
topLabel="Cancellation Reason *"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
placeholder="Enter reason for cancellation..."
|
||||
multiline
|
||||
className="h-24"
|
||||
/>
|
||||
|
||||
<div className="flex flex-row gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 py-3.5 rounded-xl items-center text-gray-700 font-bold"
|
||||
>
|
||||
Keep Order
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelOrderMutation.isPending || !cancelReason.trim()}
|
||||
className={`flex-1 bg-red-500 py-3.5 rounded-xl items-center shadow-sm text-white font-bold disabled:opacity-50 ${cancelOrderMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{cancelOrderMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
)
|
||||
}
|
||||
558
apps/admin-web/src/components/CouponForm.tsx
Normal file
558
apps/admin-web/src/components/CouponForm.tsx
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
import { useState, useEffect, type ChangeEvent } from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { pInput as PInput, MyButton, AppContainer, Checkbox, BottomDialog, p as P, MyFlatList } from 'web-components'
|
||||
import DateTimeInput from './DateTimeInput'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
// Local copy of common-ui/shared-types CreateCouponPayload (web cannot import
|
||||
// the RN common-ui package; @packages/shared does not export this type).
|
||||
export interface CreateCouponPayload {
|
||||
couponCodes: string[]
|
||||
discountPercent?: number
|
||||
flatDiscount?: number
|
||||
minOrder?: number
|
||||
maxValue?: number
|
||||
validTill?: string
|
||||
maxLimitForUser?: number
|
||||
isApplyForAll: boolean
|
||||
isUserBased: boolean
|
||||
targetUsers?: number[]
|
||||
productIds?: number[]
|
||||
skuIds?: number[]
|
||||
applicableUsers?: number[]
|
||||
applicableProducts?: number[]
|
||||
exclusiveApply?: boolean
|
||||
}
|
||||
|
||||
const USERS_PAGE_SIZE = 10
|
||||
|
||||
interface CouponFormValues extends CreateCouponPayload {
|
||||
isReservedCoupon?: boolean
|
||||
couponCodes: string[]
|
||||
skuIds?: number[]
|
||||
}
|
||||
|
||||
interface CouponFormProps {
|
||||
onSubmit: (values: CouponFormValues) => void
|
||||
isLoading: boolean
|
||||
initialValues?: Partial<CouponFormValues>
|
||||
}
|
||||
|
||||
const couponValidationSchema = Yup.object().shape({
|
||||
isReservedCoupon: Yup.boolean().optional(),
|
||||
couponCodes: Yup.array().of(
|
||||
Yup.string()
|
||||
.required('Code is required')
|
||||
.min(3, 'Code must be at least 3 characters')
|
||||
.max(50, 'Code cannot exceed 50 characters')
|
||||
.matches(/^[A-Z0-9_-]+$/, 'Code can only contain uppercase letters, numbers, underscores, and hyphens')
|
||||
),
|
||||
discountPercent: Yup.number()
|
||||
.min(0, 'Must be positive')
|
||||
.max(100, 'Cannot exceed 100%')
|
||||
.optional(),
|
||||
flatDiscount: Yup.number()
|
||||
.min(0, 'Must be positive')
|
||||
.optional(),
|
||||
minOrder: Yup.number().min(0, 'Must be positive').optional(),
|
||||
maxValue: Yup.number().min(0, 'Must be positive').optional(),
|
||||
validTill: Yup.date().optional(),
|
||||
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
|
||||
exclusiveApply: Yup.boolean().optional(),
|
||||
isUserBased: Yup.boolean(),
|
||||
isApplyForAll: Yup.boolean(),
|
||||
applicableUsers: Yup.array().of(Yup.number()).optional(),
|
||||
}).test('discount-validation', 'Must provide exactly one discount type with valid value', function(value) {
|
||||
const { discountPercent, flatDiscount } = value
|
||||
const hasPercent = discountPercent !== undefined && discountPercent > 0
|
||||
const hasFlat = flatDiscount !== undefined && flatDiscount > 0
|
||||
|
||||
if (hasPercent && hasFlat) {
|
||||
return this.createError({ message: 'Cannot have both percentage and flat discount' })
|
||||
}
|
||||
if (!hasPercent && !hasFlat) {
|
||||
return this.createError({ message: 'Must provide either percentage or flat discount' })
|
||||
}
|
||||
return true
|
||||
}).test('codes-required', 'At least one coupon code is required', function(value: any) {
|
||||
const codes = (value.couponCodes || []).filter((c: string) => c && c.trim())
|
||||
if (codes.length === 0) {
|
||||
return this.createError({ path: 'couponCodes', message: 'Add at least one coupon code' })
|
||||
}
|
||||
return true
|
||||
}).test('codes-unique', 'Coupon codes must be unique', function(value: any) {
|
||||
const codes = (value.couponCodes || []).map((c: string) => c.trim()).filter(Boolean)
|
||||
if (new Set(codes).size !== codes.length) {
|
||||
return this.createError({ path: 'couponCodes', message: 'Coupon codes must be unique' })
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default function CouponForm({ onSubmit, isLoading, initialValues }: CouponFormProps) {
|
||||
|
||||
// User dropdown states
|
||||
const [userSearchQuery, setUserSearchQuery] = useState('')
|
||||
const [userOffset, setUserOffset] = useState(0)
|
||||
const [allUsers, setAllUsers] = useState<{ id: number; name: string; mobile: string | null }[]>([])
|
||||
const [hasMoreUsers, setHasMoreUsers] = useState(true)
|
||||
const [usersDropdownOpen, setUsersDropdownOpen] = useState(false)
|
||||
|
||||
// Add-multiple dialog states (for pattern codes like ABC12)
|
||||
const [multiDialogOpen, setMultiDialogOpen] = useState(false)
|
||||
const [multiCount, setMultiCount] = useState('')
|
||||
|
||||
const { data: usersData, isFetching: isFetchingUsers } = trpc.admin.coupon.getUsersMiniInfo.useQuery(
|
||||
{ search: userSearchQuery, limit: USERS_PAGE_SIZE, offset: userOffset },
|
||||
{ enabled: usersDropdownOpen }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (usersData?.users) {
|
||||
if (userOffset === 0) {
|
||||
setAllUsers(usersData.users)
|
||||
} else {
|
||||
setAllUsers(prev => [...prev, ...usersData.users])
|
||||
}
|
||||
setHasMoreUsers(usersData.users.length === USERS_PAGE_SIZE)
|
||||
}
|
||||
}, [usersData, userOffset])
|
||||
|
||||
useEffect(() => {
|
||||
setUserOffset(0)
|
||||
setHasMoreUsers(true)
|
||||
}, [userSearchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (usersDropdownOpen) {
|
||||
setUserOffset(0)
|
||||
setAllUsers([])
|
||||
setHasMoreUsers(true)
|
||||
setUserSearchQuery('')
|
||||
}
|
||||
}, [usersDropdownOpen])
|
||||
|
||||
// User search functionality will be inside Formik
|
||||
|
||||
const defaultValues: CouponFormValues = {
|
||||
couponCodes: [''],
|
||||
isUserBased: false,
|
||||
isApplyForAll: false,
|
||||
targetUsers: [],
|
||||
discountPercent: undefined,
|
||||
flatDiscount: undefined,
|
||||
minOrder: undefined,
|
||||
maxValue: undefined,
|
||||
validTill: undefined,
|
||||
maxLimitForUser: undefined,
|
||||
skuIds: undefined,
|
||||
applicableUsers: [],
|
||||
applicableProducts: [],
|
||||
exclusiveApply: false,
|
||||
isReservedCoupon: false,
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<CouponFormValues>
|
||||
initialValues={(initialValues || defaultValues) as CouponFormValues}
|
||||
validationSchema={couponValidationSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ values, errors, touched, setFieldValue, handleSubmit }) => {
|
||||
|
||||
const toggleUserSelection = (userId: number) => {
|
||||
const current = values.applicableUsers || []
|
||||
const newSelection = current.includes(userId)
|
||||
? current.filter(id => id !== userId)
|
||||
: [...current, userId]
|
||||
setFieldValue('applicableUsers', newSelection)
|
||||
}
|
||||
|
||||
const isReserved = (values as any).isReservedCoupon
|
||||
|
||||
// Detect pattern codes like ABC12 (ends in digits) for bulk generation.
|
||||
// Continue numbering from the highest code with the same prefix.
|
||||
const patternCode = (values.couponCodes || []).find(
|
||||
(c: string) => /^[A-Z0-9_-]*\d+$/.test(c.trim())
|
||||
)
|
||||
const patternPrefix = patternCode ? patternCode.replace(/\d+$/, '') : ''
|
||||
const patternStart = (values.couponCodes || []).reduce((max, c) => {
|
||||
const trimmed = c.trim()
|
||||
if (!patternPrefix || !trimmed.startsWith(patternPrefix)) return max
|
||||
const m = trimmed.match(/\d+$/)
|
||||
if (!m) return max
|
||||
return Math.max(max, parseInt(m[0], 10))
|
||||
}, patternCode ? parseInt((patternCode.match(/\d+$/) || ['0'])[0], 10) : 0)
|
||||
|
||||
const handleAddMultiple = () => {
|
||||
const count = parseInt(multiCount, 10)
|
||||
if (!count || count < 1 || count > 100) return
|
||||
// Start from the next number after the highest existing code
|
||||
const newCodes = Array.from(
|
||||
{ length: count },
|
||||
(_, i) => `${patternPrefix}${patternStart + 1 + i}`
|
||||
)
|
||||
const existing = (values.couponCodes || []).filter((c: string) => c && c.trim())
|
||||
setFieldValue('couponCodes', [...existing, ...newCodes])
|
||||
setMultiDialogOpen(false)
|
||||
setMultiCount('')
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
{/* Is Reserved Coupon Checkbox */}
|
||||
<div className="mb-4 flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={(values as any).isReservedCoupon || false}
|
||||
onPress={() => setFieldValue('isReservedCoupon', !(values as any).isReservedCoupon)}
|
||||
/>
|
||||
<P className="ml-2 text-sm font-medium text-gray-700">Is Reserved Coupon</P>
|
||||
</div>
|
||||
|
||||
{/* Coupon Codes */}
|
||||
<div className="mb-4">
|
||||
<P className="text-base font-bold mb-1 block">Coupon Codes</P>
|
||||
<P className="text-xs text-gray-500 mb-2 block">
|
||||
Each code creates a separate coupon with the same settings.
|
||||
</P>
|
||||
{(values.couponCodes || []).map((code: string, index: number) => {
|
||||
const codeError = Array.isArray(errors.couponCodes)
|
||||
? (errors.couponCodes as any)[index]
|
||||
: errors.couponCodes
|
||||
return (
|
||||
<div key={index} className="flex flex-row items-center mb-2">
|
||||
<div className="flex-1">
|
||||
<PInput
|
||||
topLabel={`Code ${index + 1}`}
|
||||
placeholder="e.g., SECRET10"
|
||||
value={code}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const next = [...(values.couponCodes || [])]
|
||||
next[index] = e.target.value.toUpperCase()
|
||||
setFieldValue('couponCodes', next)
|
||||
}}
|
||||
error={!!codeError}
|
||||
/>
|
||||
{codeError && <P className="text-red-500 text-xs mt-1 block">{codeError}</P>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = (values.couponCodes || []).filter((_: string, i: number) => i !== index)
|
||||
setFieldValue('couponCodes', next.length > 0 ? next : [''])
|
||||
}}
|
||||
className="ml-2 p-2 text-red-500 font-bold text-lg"
|
||||
aria-label={`Remove code ${index + 1}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{(errors.couponCodes as any)?.message && (
|
||||
<P className="text-red-500 text-xs mb-2 block">{(errors.couponCodes as any).message}</P>
|
||||
)}
|
||||
<div className="mt-2 flex flex-row self-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFieldValue('couponCodes', [...(values.couponCodes || []), ''])}
|
||||
className="py-2 px-3 bg-blue-50 rounded-lg border border-blue-200 text-blue-600 font-medium"
|
||||
>
|
||||
+ Add Code
|
||||
</button>
|
||||
|
||||
{patternCode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMultiDialogOpen(true)}
|
||||
className="ml-2 py-2 px-3 bg-green-50 rounded-lg border border-green-200 text-green-600 font-medium"
|
||||
>
|
||||
Add Multiple
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discount Type Selection */}
|
||||
<P className="text-base font-bold mb-2 block">
|
||||
Discount Type *
|
||||
</P>
|
||||
|
||||
<div className="flex flex-row mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFieldValue('discountPercent', values.discountPercent || 0)
|
||||
setFieldValue('flatDiscount', undefined)
|
||||
}}
|
||||
className={`flex-1 p-3 border rounded-lg mr-2 ${
|
||||
values.discountPercent !== undefined ? 'border-blue-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<P className="block text-center">Percentage</P>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFieldValue('flatDiscount', values.flatDiscount || 0)
|
||||
setFieldValue('discountPercent', undefined)
|
||||
}}
|
||||
className={`flex-1 p-3 border rounded-lg ${
|
||||
values.flatDiscount !== undefined ? 'border-blue-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<P className="block text-center">Flat Amount</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Discount Value */}
|
||||
{values.discountPercent !== undefined && (
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Discount Percentage *"
|
||||
placeholder="e.g., 10"
|
||||
value={values.discountPercent?.toString() || ''}
|
||||
onChange={(e) => setFieldValue('discountPercent', parseFloat(e.target.value) || 0)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
error={!!(touched.discountPercent && errors.discountPercent)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.flatDiscount !== undefined && (
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Flat Discount Amount *"
|
||||
placeholder="e.g., 50"
|
||||
value={values.flatDiscount?.toString() || ''}
|
||||
onChange={(e) => setFieldValue('flatDiscount', parseFloat(e.target.value) || 0)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
error={!!(touched.flatDiscount && errors.flatDiscount)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimum Order */}
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Minimum Order Amount"
|
||||
placeholder="e.g., 100"
|
||||
value={values.minOrder?.toString() || ''}
|
||||
onChange={(e) => setFieldValue('minOrder', parseFloat(e.target.value) || undefined)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
error={!!(touched.minOrder && errors.minOrder)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Maximum Discount Value - only for percentage discounts */}
|
||||
{values.flatDiscount === undefined && (
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Maximum Discount Value"
|
||||
placeholder="e.g., 200"
|
||||
value={values.maxValue?.toString() || ''}
|
||||
onChange={(e) => setFieldValue('maxValue', parseFloat(e.target.value) || undefined)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
error={!!(touched.maxValue && errors.maxValue)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validity Period */}
|
||||
<div className="mb-4">
|
||||
<P className="text-base mb-2 block">Valid Till</P>
|
||||
<DateTimeInput
|
||||
value={values.validTill ? new Date(values.validTill) : null}
|
||||
setValue={(date) => {
|
||||
|
||||
setFieldValue('validTill', date?.toISOString())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage Limit */}
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Max Uses Per User"
|
||||
placeholder="e.g., 5"
|
||||
value={values.maxLimitForUser?.toString() || ''}
|
||||
onChange={(e) => setFieldValue('maxLimitForUser', parseInt(e.target.value) || undefined)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
error={!!(touched.maxLimitForUser && errors.maxLimitForUser)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Exclusive Apply */}
|
||||
<div className="mb-4">
|
||||
<P className="text-base mb-2 block">Exclusive Apply</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFieldValue('exclusiveApply', !values.exclusiveApply)}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<div className={`w-5 h-5 border-2 border-gray-300 rounded mr-3 flex items-center justify-center ${values.exclusiveApply ? 'bg-blue-500 border-blue-500' : ''}`}>
|
||||
{values.exclusiveApply && <P className="text-white text-center">✓</P>}
|
||||
</div>
|
||||
<P className="text-gray-700">Exclusive coupon (cannot be combined with other coupons)</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Target Audience */}
|
||||
<P className={`text-base font-bold mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>
|
||||
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}
|
||||
</P>
|
||||
|
||||
<div className="flex flex-row mb-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReserved}
|
||||
onClick={isReserved ? undefined : () => {
|
||||
setFieldValue('isApplyForAll', true)
|
||||
setFieldValue('isUserBased', false)
|
||||
setFieldValue('targetUsers', [])
|
||||
}}
|
||||
className={`flex-1 p-3 border rounded-lg mr-2 ${
|
||||
values.isApplyForAll ? 'border-blue-500' : 'border-gray-300'
|
||||
} ${isReserved ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>All Users</P>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReserved}
|
||||
onClick={isReserved ? undefined : () => {
|
||||
setFieldValue('isUserBased', true)
|
||||
setFieldValue('isApplyForAll', false)
|
||||
}}
|
||||
className={`flex-1 p-3 border rounded-lg ${
|
||||
values.isUserBased ? 'border-blue-500' : 'border-gray-300'
|
||||
} ${isReserved ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<P className="block text-center" style={{ color: isReserved ? '#9CA3AF' : '#000' }}>Specific User</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Applicable User Selection */}
|
||||
<div className="mb-4">
|
||||
<P className={`text-base mb-2 block ${isReserved ? 'text-gray-400' : ''}`}>Applicable Users (Optional)</P>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReserved}
|
||||
onClick={isReserved ? undefined : () => setUsersDropdownOpen(true)}
|
||||
className={`border border-gray-300 rounded p-3 bg-white w-full text-left ${isReserved ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<P className={isReserved ? 'text-gray-400' : 'text-gray-700'}>
|
||||
{values.applicableUsers?.length ? `${values.applicableUsers.length} users selected` : 'Select users...'}
|
||||
</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BottomDialog open={usersDropdownOpen} onClose={() => setUsersDropdownOpen(false)}>
|
||||
<div className="p-4">
|
||||
<P className="text-lg font-semibold mb-4 block">Select Applicable Users</P>
|
||||
<PInput
|
||||
placeholder="Search users by name or mobile..."
|
||||
value={userSearchQuery}
|
||||
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="overflow-auto" style={{ maxHeight: 240 }}>
|
||||
<MyFlatList
|
||||
data={allUsers}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleUserSelection(item.id)}
|
||||
className="flex flex-row items-center p-3 border-b border-gray-200 w-full text-left"
|
||||
>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={values.applicableUsers?.includes(item.id) || false}
|
||||
onPress={() => toggleUserSelection(item.id)}
|
||||
/>
|
||||
</span>
|
||||
<P className="ml-3 text-base">{item.mobile} - {item.name}</P>
|
||||
</button>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
{isFetchingUsers ? <P className="text-center p-3 block">Loading...</P> : null}
|
||||
{hasMoreUsers && !isFetchingUsers ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserOffset(prev => prev + USERS_PAGE_SIZE)}
|
||||
className="w-full p-3 text-center text-blue-600 font-medium"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUsersDropdownOpen(false)}
|
||||
className="mt-4 bg-blue-500 p-3 rounded text-white text-center font-semibold w-full"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Add Multiple Coupons Dialog */}
|
||||
<BottomDialog open={multiDialogOpen} onClose={() => setMultiDialogOpen(false)}>
|
||||
<div className="p-4">
|
||||
<P className="text-lg font-semibold mb-1 block">Add Multiple Coupons</P>
|
||||
<P className="text-sm text-gray-500 mb-4 block">
|
||||
Generate {patternCode ? `${patternPrefix}${patternStart + 1}, ${patternPrefix}${patternStart + 2}, ...` : 'sequential'} codes from your pattern
|
||||
</P>
|
||||
<PInput
|
||||
topLabel="How many?"
|
||||
placeholder="e.g., 10"
|
||||
value={multiCount}
|
||||
onChange={(e) => setMultiCount(e.target.value)}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="flex flex-row mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMultiDialogOpen(false)
|
||||
setMultiCount('')
|
||||
}}
|
||||
className="flex-1 p-3 rounded bg-gray-100 mr-2 text-gray-700 text-center font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMultiple}
|
||||
className="flex-1 p-3 rounded bg-green-500 text-white text-center font-semibold"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Submit Button */}
|
||||
<MyButton
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isLoading}
|
||||
textContent={isLoading ? 'Creating...' : 'Create Coupon'}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
31
apps/admin-web/src/components/DateInput.tsx
Normal file
31
apps/admin-web/src/components/DateInput.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { p as P } from 'web-components'
|
||||
|
||||
interface DateInputProps {
|
||||
value: Date | null
|
||||
setValue: (d: Date | null) => void
|
||||
showLabel?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function toDateStr(d: Date | null): string {
|
||||
if (!d) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export default function DateInput({ value, setValue, showLabel = true, placeholder = 'Select Date' }: DateInputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{showLabel ? <P className="text-xs mb-1">{placeholder}</P> : null}
|
||||
<input
|
||||
type="date"
|
||||
value={toDateStr(value)}
|
||||
onChange={(e) => setValue(e.target.value ? new Date(`${e.target.value}T00:00:00`) : null)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded border border-gray-300 px-2 py-2 text-sm font-medium text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
apps/admin-web/src/components/DateTimeInput.tsx
Normal file
80
apps/admin-web/src/components/DateTimeInput.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { p as P } from 'web-components'
|
||||
|
||||
interface DateTimeInputProps {
|
||||
value: Date | null
|
||||
setValue: (d: Date | null) => void
|
||||
showLabels?: boolean
|
||||
timeOnly?: boolean
|
||||
}
|
||||
|
||||
function toDateStr(d: Date | null): string {
|
||||
if (!d) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
function toTimeStr(d: Date | null): string {
|
||||
if (!d) return ''
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function DateTimeInput({ value, setValue, showLabels = true, timeOnly = false }: DateTimeInputProps) {
|
||||
const handleDateChange = (dateStr: string) => {
|
||||
if (!dateStr) {
|
||||
setValue(null)
|
||||
return
|
||||
}
|
||||
const base = value ? new Date(value) : new Date()
|
||||
const [y, m, day] = dateStr.split('-').map((n) => parseInt(n, 10))
|
||||
base.setFullYear(y, m - 1, day)
|
||||
setValue(base)
|
||||
}
|
||||
|
||||
const handleTimeChange = (timeStr: string) => {
|
||||
if (!timeStr) return
|
||||
const base = value ? new Date(value) : new Date()
|
||||
const [h, min] = timeStr.split(':').map((n) => parseInt(n, 10))
|
||||
base.setHours(h, min, 0, 0)
|
||||
setValue(base)
|
||||
}
|
||||
|
||||
if (timeOnly) {
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
|
||||
<input
|
||||
type="time"
|
||||
value={toTimeStr(value)}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row items-stretch mb-4">
|
||||
<div className="w-1/2">
|
||||
{showLabels ? <P className="text-xs mb-1">Select Date</P> : null}
|
||||
<input
|
||||
type="date"
|
||||
value={toDateStr(value)}
|
||||
onChange={(e) => handleDateChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-2" />
|
||||
<div className="w-1/2">
|
||||
{showLabels ? <P className="text-xs mb-1">Select Time</P> : null}
|
||||
<input
|
||||
type="time"
|
||||
value={toTimeStr(value)}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm font-medium text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
apps/admin-web/src/components/MultiSelect.tsx
Normal file
151
apps/admin-web/src/components/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { BottomDialog, Checkbox, SearchBar, p as P } from 'web-components'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string
|
||||
value: string | number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
label?: string
|
||||
topLabel?: string
|
||||
options: MultiSelectOption[]
|
||||
value: (string | number)[]
|
||||
onValueChange: (v: (string | number)[]) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
onSearch?: (q: string) => void
|
||||
error?: boolean
|
||||
className?: string
|
||||
testID?: string
|
||||
// Tolerated for screen compatibility (RN BottomDropdown leftovers) — ignored on web
|
||||
multiple?: boolean
|
||||
triggerComponent?: any
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
label,
|
||||
topLabel,
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
onSearch,
|
||||
error = false,
|
||||
className,
|
||||
testID,
|
||||
}: MultiSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const selectedSet = useMemo(() => new Set(value.map((v) => String(v))), [value])
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (value.length === 0) return placeholder ?? label ?? 'Select...'
|
||||
if (value.length === 1) {
|
||||
const found = options.find((o) => String(o.value) === String(value[0]))
|
||||
return found ? found.label : (placeholder ?? label ?? 'Select...')
|
||||
}
|
||||
return `${value.length} selected`
|
||||
}, [value, options, placeholder, label])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return options
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||
}, [options, query])
|
||||
|
||||
const toggle = (optionValue: string | number) => {
|
||||
const key = String(optionValue)
|
||||
if (selectedSet.has(key)) {
|
||||
onValueChange(value.filter((v) => String(v) !== key))
|
||||
} else {
|
||||
onValueChange([...value, optionValue])
|
||||
}
|
||||
}
|
||||
|
||||
const heading = topLabel ?? label
|
||||
|
||||
return (
|
||||
<div className={className} data-testid={testID}>
|
||||
{heading ? (
|
||||
<P className="mb-1 text-sm text-gray-500 font-medium">{heading}</P>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
|
||||
>
|
||||
<span className={`truncate ${value.length === 0 ? 'text-gray-400' : 'text-gray-800'}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
<BottomDialog open={open} onClose={() => setOpen(false)}>
|
||||
<div className="p-4">
|
||||
<P className="text-lg font-semibold mb-4">{heading ?? placeholder ?? 'Select'}</P>
|
||||
<SearchBar
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(q) => {
|
||||
setQuery(q)
|
||||
onSearch?.(q)
|
||||
}}
|
||||
onSearch={onSearch}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
|
||||
{filtered.map((option) => {
|
||||
const checked = selectedSet.has(String(option.value))
|
||||
return (
|
||||
<div
|
||||
key={String(option.value)}
|
||||
onClick={() => {
|
||||
if (!option.disabled) toggle(option.value)
|
||||
}}
|
||||
className={`flex flex-row items-center p-3 border-b border-gray-100 ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'}`}
|
||||
>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onPress={() => {
|
||||
if (!option.disabled) toggle(option.value)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="ml-3 text-sm text-gray-800 flex-1">{option.label}</span>
|
||||
{checked ? <Check className="h-4 w-4 text-blue-600" /> : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange([])}
|
||||
className="flex-1 p-3 rounded-lg bg-gray-100 text-gray-700 text-center font-semibold text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex-1 p-3 rounded-lg bg-blue-500 text-white text-center font-semibold text-sm"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MultiSelect
|
||||
70
apps/admin-web/src/components/OrderNotesForm.tsx
Normal file
70
apps/admin-web/src/components/OrderNotesForm.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useState, type FC } from 'react'
|
||||
import { p as P, MyButton, pInput as PInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
interface OrderNotesFormProps {
|
||||
orderId: number
|
||||
initialNotes?: string
|
||||
onSuccess?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export const OrderNotesForm: FC<OrderNotesFormProps> = ({
|
||||
orderId,
|
||||
initialNotes = '',
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [notes, setNotes] = useState(initialNotes)
|
||||
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!notes.trim()) {
|
||||
window.alert('Error: Please enter some notes')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateNotesMutation.mutateAsync({
|
||||
orderId,
|
||||
adminNotes: notes.trim(),
|
||||
})
|
||||
window.alert('Success: Notes updated successfully')
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
window.alert('Error: Failed to update notes')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<P className="text-lg font-bold mb-4">
|
||||
Add Order Notes
|
||||
</P>
|
||||
|
||||
<PInput
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Enter notes for this order..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
className="min-h-24"
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-between mt-5 gap-3">
|
||||
<MyButton
|
||||
variant="blue"
|
||||
className="bg-gray-400 hover:bg-gray-500"
|
||||
textContent="Cancel"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<MyButton
|
||||
variant="blue"
|
||||
textContent={updateNotesMutation.isPending ? 'Saving...' : 'Save Notes'}
|
||||
onClick={handleSubmit}
|
||||
disabled={updateNotesMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
327
apps/admin-web/src/components/OrderOptionsMenu.tsx
Normal file
327
apps/admin-web/src/components/OrderOptionsMenu.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import React from 'react'
|
||||
import { p as P, BottomDialog, Checkbox } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ChevronRight, Eye, Pencil, XCircle, MapPin, Map, MessageCircle, Phone } from 'lucide-react'
|
||||
|
||||
interface OrderOptionsMenuProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
order: {
|
||||
id: number
|
||||
readableId: number
|
||||
isPackaged: boolean
|
||||
isDelivered: boolean
|
||||
isFlashDelivery?: boolean
|
||||
address: string
|
||||
addressId: number
|
||||
adminNotes?: string | null
|
||||
userNotes?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
status?: string
|
||||
}
|
||||
onViewDetails: () => void
|
||||
onTogglePackaged: () => void
|
||||
onToggleDelivered: () => void
|
||||
onOpenAdminNotes: () => void
|
||||
onCancelOrder: () => void
|
||||
onAttachLocation: () => void
|
||||
onWhatsApp: () => void
|
||||
onDial: () => void
|
||||
}
|
||||
|
||||
function getCurrentPosition(): Promise<GeolocationPosition> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not supported by this browser'))
|
||||
return
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function OrderOptionsMenu({
|
||||
open,
|
||||
onClose,
|
||||
order,
|
||||
onViewDetails,
|
||||
onTogglePackaged,
|
||||
onToggleDelivered,
|
||||
onOpenAdminNotes,
|
||||
onCancelOrder,
|
||||
onAttachLocation,
|
||||
onWhatsApp,
|
||||
onDial,
|
||||
}: OrderOptionsMenuProps) {
|
||||
const updateAddressCoordsMutation = trpc.admin.order.updateAddressCoords.useMutation()
|
||||
|
||||
const handleAttachLocation = async () => {
|
||||
try {
|
||||
const location = await getCurrentPosition()
|
||||
const { latitude, longitude } = location.coords
|
||||
await updateAddressCoordsMutation.mutateAsync({
|
||||
addressId: order.addressId,
|
||||
latitude,
|
||||
longitude,
|
||||
})
|
||||
window.alert('Success: Location attached to address successfully.')
|
||||
onAttachLocation()
|
||||
} catch (error: any) {
|
||||
if (error?.code === 1) {
|
||||
window.alert('Permission Denied: Location permission is required to attach coordinates.')
|
||||
return
|
||||
}
|
||||
window.alert('Error: Failed to attach location. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
const extractPhone = (address: string) => {
|
||||
const phoneMatch = address.match(/Phone: (\d+)/)
|
||||
return phoneMatch ? phoneMatch[1] : null
|
||||
}
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const phone = extractPhone(order.address)
|
||||
if (phone) {
|
||||
window.open(`https://wa.me/91${phone}`, '_blank')
|
||||
} else {
|
||||
window.alert('No phone number found')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDial = () => {
|
||||
const phone = extractPhone(order.address)
|
||||
if (phone) {
|
||||
window.open(`tel:${phone}`, '_blank')
|
||||
} else {
|
||||
window.alert('No phone number found')
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenInMaps = () => {
|
||||
if (order.latitude && order.longitude) {
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${order.latitude},${order.longitude}`
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
window.alert('No location coordinates available')
|
||||
}
|
||||
}
|
||||
|
||||
const hasCoordinates = order.latitude !== null && order.latitude !== undefined &&
|
||||
order.longitude !== null && order.longitude !== undefined
|
||||
|
||||
return (
|
||||
<BottomDialog open={open} onClose={onClose}>
|
||||
<div style={{ maxHeight: '70vh' }} className="overflow-hidden flex flex-col">
|
||||
<div className="overflow-auto grow">
|
||||
<div className="pb-8 pt-2 px-4">
|
||||
<div className="items-center mb-6 flex flex-col">
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full mb-4" />
|
||||
<P className="text-lg font-bold text-gray-900">
|
||||
Order #{order.readableId}
|
||||
</P>
|
||||
<P className="text-sm text-gray-500">
|
||||
Select an action to perform
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onViewDetails()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<Eye className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
View Details
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
See full order information
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
|
||||
<button type="button" onClick={onTogglePackaged} className="p-1" aria-label="Toggle packaged">
|
||||
<Checkbox
|
||||
checked={order.isPackaged}
|
||||
onPress={onTogglePackaged}
|
||||
fillColor={order.isPackaged ? '#10B981' : '#1570EF'}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" onClick={onTogglePackaged} className="ml-3 flex-1 text-left">
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Packaged
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
{order.isPackaged ? 'Mark as not packaged' : 'Mark as packaged'}
|
||||
</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm">
|
||||
<button type="button" onClick={onToggleDelivered} className="p-1" aria-label="Toggle delivered">
|
||||
<Checkbox
|
||||
checked={order.isDelivered}
|
||||
onPress={onToggleDelivered}
|
||||
fillColor={order.isDelivered ? '#10B981' : '#1570EF'}
|
||||
/>
|
||||
</button>
|
||||
<button type="button" onClick={onToggleDelivered} className="ml-3 flex-1 text-left">
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Delivered
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
{order.isDelivered ? 'Mark as not delivered' : 'Mark as delivered'}
|
||||
</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpenAdminNotes()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-yellow-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<Pencil className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Admin Notes
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
{order.adminNotes ? 'Edit existing notes' : 'Add admin notes'}
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
|
||||
{order.status !== 'cancelled' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onCancelOrder()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-red-700 text-base block">
|
||||
Cancel Order
|
||||
</P>
|
||||
<P className="text-red-500 text-xs block">
|
||||
Cancel and provide reason
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleAttachLocation()
|
||||
onClose()
|
||||
}}
|
||||
disabled={updateAddressCoordsMutation.isPending}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-orange-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<MapPin className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Attach Location
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
Save GPS coordinates to address
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
|
||||
{hasCoordinates && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleOpenInMaps()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<Map className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Open in Maps
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
View delivery location on Google Maps
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleWhatsApp()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<MessageCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Message On WhatsApp
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
Send message via WhatsApp
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleDial()
|
||||
onClose()
|
||||
}}
|
||||
className="flex flex-row items-center p-4 bg-white border border-gray-100 rounded-xl mb-3 shadow-sm w-full text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-50 items-center justify-center mr-4 flex shrink-0">
|
||||
<Phone className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<P className="font-semibold text-gray-800 text-base block">
|
||||
Dial Mobile Number
|
||||
</P>
|
||||
<P className="text-gray-500 text-xs block">
|
||||
Call customer directly
|
||||
</P>
|
||||
</div>
|
||||
<ChevronRight className="h-6 w-6 text-gray-400 ml-auto" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
)
|
||||
}
|
||||
659
apps/admin-web/src/components/ProductForm.tsx
Normal file
659
apps/admin-web/src/components/ProductForm.tsx
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import { useState, useImperativeHandle, forwardRef, useMemo } from 'react'
|
||||
import { Formik, FieldArray } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { pInput as PInput, Dropdown, p as P, Checkbox, BottomDialog, ImageUploaderNeo } from 'web-components'
|
||||
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
|
||||
import SearchableSelect from './SearchableSelect'
|
||||
import { Plus, Trash2, X, RotateCcw, Info } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { CreateComboItemInput, CreateSkuInput } from '@packages/shared'
|
||||
import type { SkuFeatureLike } from '@packages/shared'
|
||||
|
||||
// Feature input shape — single source: shared SkuFeatureLike
|
||||
type Attribute = SkuFeatureLike
|
||||
|
||||
// Variant form state — anchored to the shared CreateSkuInput (numeric prices,
|
||||
// same field names). Images are managed separately via variantImages state.
|
||||
type Variant = Omit<
|
||||
CreateSkuInput,
|
||||
'name' | 'price' | 'marketPrice' | 'flashPrice' | 'images' | 'features' | 'comboItems'
|
||||
> & {
|
||||
id?: number
|
||||
name: string
|
||||
price?: number
|
||||
marketPrice: number | null
|
||||
flashPrice: number | null
|
||||
isFlashAvailable: boolean
|
||||
isOffer: boolean
|
||||
isComboOnly: boolean
|
||||
isSuspended: boolean
|
||||
features: Attribute[]
|
||||
comboItems: CreateComboItemInput[]
|
||||
}
|
||||
|
||||
interface ProductFormData {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
storeId: number
|
||||
productType: 'item' | 'combo'
|
||||
variants: Variant[]
|
||||
}
|
||||
|
||||
export interface ProductFormRef {
|
||||
clearImages: () => void
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
initialValues: ProductFormData
|
||||
onSubmit: (values: ProductFormData, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => void
|
||||
isLoading: boolean
|
||||
existingVariantImages?: ImageUploaderNeoItem[][]
|
||||
existingVariantImageKeys?: string[][]
|
||||
}
|
||||
|
||||
// Web rebuild of common-ui InfoDialog: info icon button + BottomDialog + p
|
||||
function InfoDialog({ message, className }: { message?: string; className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)} className={className} aria-label="More info">
|
||||
<Info className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
<BottomDialog open={open} onClose={() => setOpen(false)}>
|
||||
<div className="py-4 px-2">
|
||||
<P className="text-gray-700 text-sm leading-6">{message ?? ''}</P>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultAttribute = (): Attribute => ({ featureName: '', featureValue: '' })
|
||||
|
||||
// The quantity feature is auto-added as the first attribute of every SKU.
|
||||
const quantityAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
|
||||
|
||||
const isQuantityFeature = (attr: Attribute): boolean =>
|
||||
(attr.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
|
||||
const defaultVariant = (): Variant => ({
|
||||
id: undefined,
|
||||
name: '',
|
||||
price: undefined,
|
||||
marketPrice: null,
|
||||
isFlashAvailable: false,
|
||||
flashPrice: null,
|
||||
isOffer: false,
|
||||
isComboOnly: false,
|
||||
isDeleted: false,
|
||||
isSuspended: false,
|
||||
features: [quantityAttribute()],
|
||||
comboItems: [],
|
||||
})
|
||||
|
||||
const variantSignature = (attributes: Attribute[]): string =>
|
||||
attributes
|
||||
.map((a) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const productValidationSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Product name is required'),
|
||||
storeId: Yup.number().required('Store is required').min(1, 'Store is required'),
|
||||
productType: Yup.string().oneOf(['item', 'combo'], 'Product type is required').required('Product type is required'),
|
||||
variants: Yup.array()
|
||||
.min(1, 'At least one variant is required')
|
||||
.of(
|
||||
Yup.object().shape({
|
||||
price: Yup.number()
|
||||
.typeError('Price must be a number')
|
||||
.positive('Price must be a positive number')
|
||||
.required('Price is required'),
|
||||
marketPrice: Yup.number()
|
||||
.typeError('Market price must be a number')
|
||||
.min(0, 'Market price cannot be negative')
|
||||
.nullable()
|
||||
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
||||
.optional(),
|
||||
flashPrice: Yup.number()
|
||||
.typeError('Flash price must be a number')
|
||||
.min(0, 'Flash price cannot be negative')
|
||||
.nullable()
|
||||
.transform((value, originalValue) => (originalValue === '' ? null : value))
|
||||
.optional(),
|
||||
features: Yup.array()
|
||||
.min(1, 'At least one attribute is required')
|
||||
.of(
|
||||
Yup.object().shape({
|
||||
featureValue: Yup.string().required('Value is required'),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.test('unique-variants', 'Two variants have the same attributes', function (variants) {
|
||||
if (!Array.isArray(variants)) return true
|
||||
const seen = new Set<string>()
|
||||
for (const variant of variants) {
|
||||
const attrs = variant?.features || []
|
||||
const signature = variantSignature(attrs as Attribute[])
|
||||
if (seen.has(signature)) {
|
||||
return this.createError({ message: 'Two variants have the same attributes' })
|
||||
}
|
||||
seen.add(signature)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.test('quantity-feature', 'Each SKU must have exactly one quantity feature', function (variants) {
|
||||
if (!Array.isArray(variants)) return true
|
||||
for (const variant of variants) {
|
||||
const attrs = variant?.features || []
|
||||
const quantityCount = attrs.filter(
|
||||
(a) => ((a as Attribute)?.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
).length
|
||||
if (quantityCount === 0) {
|
||||
return this.createError({ message: 'Every SKU must have a quantity feature' })
|
||||
}
|
||||
if (quantityCount > 1) {
|
||||
return this.createError({ message: 'Each SKU can have only one quantity feature' })
|
||||
}
|
||||
}
|
||||
return true
|
||||
}),
|
||||
})
|
||||
|
||||
// Collect every string message from a Formik errors tree (objects, arrays, strings).
|
||||
// Variant-level errors (e.g. 'Each SKU can have only one quantity feature') can get
|
||||
// hidden by Formik when index-level errors are also present, so we walk everything.
|
||||
const collectErrorMessages = (errors: unknown, depth = 0): string[] => {
|
||||
if (depth > 6) return []
|
||||
if (typeof errors === 'string') return [errors]
|
||||
if (Array.isArray(errors)) return errors.flatMap((e) => collectErrorMessages(e, depth + 1))
|
||||
if (errors && typeof errors === 'object') {
|
||||
return Object.values(errors).flatMap((e) => collectErrorMessages(e, depth + 1))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
|
||||
mode,
|
||||
initialValues,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
existingVariantImages = [],
|
||||
existingVariantImageKeys = [],
|
||||
}, ref) => {
|
||||
const [variantImages, setVariantImages] = useState<ImageUploaderNeoItem[][]>(() =>
|
||||
initialValues.variants.length > 0
|
||||
? initialValues.variants.map((_, i) => existingVariantImages[i] || [])
|
||||
: [[]]
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearImages: () => setVariantImages(initialValues.variants.map(() => [])),
|
||||
}), [initialValues.variants])
|
||||
|
||||
const { data: storesData } = trpc.common.getStoresSummary.useQuery()
|
||||
const storeOptions = storesData?.stores.map((store) => ({
|
||||
label: store.name,
|
||||
value: store.id,
|
||||
})) || []
|
||||
|
||||
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
|
||||
const skuOptions = (skusData?.skus || []).map((sku) => ({
|
||||
label: sku.label,
|
||||
value: sku.id,
|
||||
}))
|
||||
|
||||
// Make sure every SKU starts with a 'quantity' feature (auto-added at the
|
||||
// beginning if the provided initial values don't have one).
|
||||
const formInitialValues = useMemo(() => {
|
||||
return {
|
||||
...initialValues,
|
||||
variants: (initialValues.variants || []).map((v) => {
|
||||
const hasQuantity = (v.features || []).some(
|
||||
(a) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
)
|
||||
if (hasQuantity) return v
|
||||
return { ...v, features: [quantityAttribute(), ...(v.features || [])] }
|
||||
}),
|
||||
}
|
||||
}, [initialValues])
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
validationSchema={productValidationSchema}
|
||||
onSubmit={(values) => {
|
||||
// Normalize feature names before sending to backend: the mandatory
|
||||
// 'quantity' feature must always go out lowercase (case-insensitive
|
||||
// matching means 'Quantity'/'QUANTITY' are treated the same), and
|
||||
// names are trimmed like the backend does.
|
||||
// TextInputs emit strings — coerce price fields to the numeric types
|
||||
// the backend zod schema requires (price required; market/flash nullable).
|
||||
const toNumber = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const normalizedValues: ProductFormData = {
|
||||
...values,
|
||||
variants: values.variants.map((v) => ({
|
||||
...v,
|
||||
price: toNumber(v.price) ?? undefined,
|
||||
marketPrice: toNumber(v.marketPrice),
|
||||
flashPrice: toNumber(v.flashPrice),
|
||||
features: v.features.map((a) => ({
|
||||
...a,
|
||||
featureName: isQuantityFeature(a) ? 'quantity' : (a.featureName ?? '').trim() || null,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
const images = variantImages.map((imgs) =>
|
||||
imgs.map((img) => ({ url: img.imgUrl, mimeType: img.mimeType }))
|
||||
)
|
||||
const deletedKeys: string[] = []
|
||||
if (mode === 'edit') {
|
||||
variantImages.forEach((currentImgs, vIndex) => {
|
||||
const existing = existingVariantImages[vIndex] || []
|
||||
existing.forEach((existingImg) => {
|
||||
if (!currentImgs.some((cur) => cur.imgUrl === existingImg.imgUrl)) {
|
||||
const key = existingVariantImageKeys[vIndex]?.[existingVariantImages[vIndex]?.indexOf(existingImg)]
|
||||
if (key) deletedKeys.push(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
onSubmit(normalizedValues, images, deletedKeys)
|
||||
}}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, validateForm }) => {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto pb-10">
|
||||
<PInput
|
||||
topLabel="Product Name"
|
||||
placeholder="Enter product name"
|
||||
value={values.name}
|
||||
onChange={handleChange('name')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<PInput
|
||||
topLabel="Short Description"
|
||||
placeholder="Enter short description"
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
value={values.shortDescription}
|
||||
onChange={handleChange('shortDescription')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<PInput
|
||||
topLabel="Long Description"
|
||||
placeholder="Enter detailed description"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
value={values.longDescription}
|
||||
onChange={handleChange('longDescription')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<P className="mb-1 text-sm text-gray-500 font-medium">Store</P>
|
||||
<Dropdown
|
||||
label="Select store"
|
||||
value={values.storeId}
|
||||
options={storeOptions}
|
||||
onValueChange={(value) => setFieldValue('storeId', Number(value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<P className="mb-1 text-sm text-gray-500 font-medium">Product Type</P>
|
||||
<Dropdown
|
||||
label="Select product type"
|
||||
value={values.productType}
|
||||
options={[
|
||||
{ label: 'Item', value: 'item' },
|
||||
{ label: 'Combo', value: 'combo' },
|
||||
]}
|
||||
onValueChange={(value) => setFieldValue('productType', value as 'item' | 'combo')}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldArray name="variants">
|
||||
{({ push, remove }) => (
|
||||
<div>
|
||||
<div className="flex flex-row justify-between items-center mb-3">
|
||||
<P className="text-lg font-bold text-gray-800">Variants</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
push(defaultVariant())
|
||||
setVariantImages((prev) => [...prev, []])
|
||||
}}
|
||||
className="bg-blue-500 px-3 py-1 rounded-lg flex flex-row items-center text-white font-semibold"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-1">Add Variant</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{values.variants.map((variant, vIndex) => {
|
||||
const isExistingSku = variant.id != null
|
||||
const isMarkedDeleted = !!variant.isDeleted
|
||||
return (
|
||||
<div key={vIndex} className={`border border-gray-300 rounded-xl p-4 mb-4 ${isMarkedDeleted ? 'border-red-300 bg-red-50 opacity-80' : ''}`}>
|
||||
<div className="flex flex-row justify-between items-center mb-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<P className="font-bold text-gray-700">Variant {vIndex + 1}</P>
|
||||
{isMarkedDeleted && (
|
||||
<div className="ml-2 px-2 py-0.5 rounded-full bg-red-100 border border-red-200">
|
||||
<P className="text-xs font-bold text-red-700 uppercase">Deleted</P>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(values.variants.length > 1 || isExistingSku) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isExistingSku) {
|
||||
// Mark existing SKU as deleted (sent to backend on save).
|
||||
setFieldValue(`variants.${vIndex}.isDeleted`, !isMarkedDeleted)
|
||||
} else {
|
||||
remove(vIndex)
|
||||
setVariantImages((prev) => prev.filter((_, i) => i !== vIndex))
|
||||
}
|
||||
}}
|
||||
aria-label={isMarkedDeleted ? 'Restore variant' : 'Delete variant'}
|
||||
>
|
||||
{isMarkedDeleted ? (
|
||||
<RotateCcw className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PInput
|
||||
topLabel="SKU Name (optional)"
|
||||
placeholder="Overrides the auto-generated name"
|
||||
value={variant.name ?? ''}
|
||||
onChange={handleChange(`variants.${vIndex}.name`)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<FieldArray name={`variants.${vIndex}.features`}>
|
||||
{({ push: pushAttr, remove: removeAttr }) => (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-row justify-between items-center mb-2">
|
||||
<P className="font-medium text-gray-600">Attributes</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pushAttr(defaultAttribute())}
|
||||
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
||||
<span className="ml-0.5">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
{variant.features.map((attr, aIndex) => {
|
||||
const quantityCount = variant.features.filter(isQuantityFeature).length
|
||||
// Quantity features are locked (non-editable), but if there
|
||||
// are duplicates (e.g. 'quantity' + 'Quantity') the user must
|
||||
// be able to delete the extras to fix the form.
|
||||
const canDelete = variant.features.length > 1 && (!isQuantityFeature(attr) || quantityCount > 1)
|
||||
return (
|
||||
<div key={aIndex} className="flex flex-row items-center mb-2 gap-2">
|
||||
<div className="flex-1">
|
||||
<PInput
|
||||
placeholder="Name"
|
||||
value={attr.featureName ?? ''}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
`variants.${vIndex}.features.${aIndex}.featureName`,
|
||||
e.target.value.trim().toLowerCase() === 'quantity' ? 'quantity' : e.target.value
|
||||
)
|
||||
}
|
||||
disabled={isQuantityFeature(attr)}
|
||||
className={isQuantityFeature(attr) ? 'text-gray-400' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<PInput
|
||||
placeholder={isQuantityFeature(attr) ? 'e.g. 0.5 kg' : 'Value'}
|
||||
value={attr.featureValue}
|
||||
onChange={handleChange(`variants.${vIndex}.features.${aIndex}.featureValue`)}
|
||||
/>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<button type="button" onClick={() => removeAttr(aIndex)} aria-label="Remove attribute">
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-row items-center self-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pushAttr(defaultAttribute())}
|
||||
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
||||
<span className="ml-0.5">Add Feature</span>
|
||||
</button>
|
||||
<InfoDialog
|
||||
message="Quantity is mandatory feature. Add other features optionally with name. Features become part of name — ex: a feature with Mini as value becomes <ItemName> Mini"
|
||||
className="ml-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
||||
<div className="flex flex-row gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<PInput
|
||||
topLabel="Market Price"
|
||||
placeholder="MRP"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={variant.marketPrice?.toString() ?? ''}
|
||||
onChange={handleChange(`variants.${vIndex}.marketPrice`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<PInput
|
||||
topLabel="Our Price"
|
||||
placeholder="Selling price"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={variant.price?.toString() ?? ''}
|
||||
onChange={handleChange(`variants.${vIndex}.price`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center mb-3">
|
||||
<Checkbox
|
||||
checked={variant.isFlashAvailable}
|
||||
onPress={() => {
|
||||
setFieldValue(`variants.${vIndex}.isFlashAvailable`, !variant.isFlashAvailable)
|
||||
if (variant.isFlashAvailable) setFieldValue(`variants.${vIndex}.flashPrice`, null)
|
||||
}}
|
||||
/>
|
||||
<P className="text-gray-700 font-medium ml-3">Flash Available</P>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center mb-3">
|
||||
<Checkbox
|
||||
checked={variant.isOffer}
|
||||
onPress={() => setFieldValue(`variants.${vIndex}.isOffer`, !variant.isOffer)}
|
||||
/>
|
||||
<P className="text-gray-700 font-medium ml-3">Offer SKU</P>
|
||||
</div>
|
||||
|
||||
{!(values.productType === 'combo' && (variant.comboItems?.length || 0) > 0) && (
|
||||
<div className="flex flex-row items-center mb-3">
|
||||
<Checkbox
|
||||
checked={variant.isComboOnly}
|
||||
onPress={() => setFieldValue(`variants.${vIndex}.isComboOnly`, !variant.isComboOnly)}
|
||||
/>
|
||||
<P className="text-gray-700 font-medium ml-3">Combo Only SKU</P>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div className="flex flex-row items-center mb-3">
|
||||
<Checkbox
|
||||
checked={variant.isSuspended}
|
||||
onPress={() => setFieldValue(`variants.${vIndex}.isSuspended`, !variant.isSuspended)}
|
||||
/>
|
||||
<P className="text-gray-700 font-medium ml-3">Suspend SKU</P>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variant.isFlashAvailable && (
|
||||
<PInput
|
||||
topLabel="Flash Price"
|
||||
placeholder="Enter flash price"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={variant.flashPrice?.toString() ?? ''}
|
||||
onChange={handleChange(`variants.${vIndex}.flashPrice`)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={variantImages[vIndex] || []}
|
||||
onImageAdd={(payloads) =>
|
||||
setVariantImages((prev) => {
|
||||
const next = [...prev]
|
||||
next[vIndex] = [...(next[vIndex] || []), ...payloads.map((pl) => ({ imgUrl: pl.url, mimeType: pl.mimeType }))]
|
||||
return next
|
||||
})
|
||||
}
|
||||
onImageRemove={(payload) =>
|
||||
setVariantImages((prev) => {
|
||||
const next = [...prev]
|
||||
next[vIndex] = (next[vIndex] || []).filter((img) => img.imgUrl !== payload.url)
|
||||
return next
|
||||
})
|
||||
}
|
||||
allowMultiple={true}
|
||||
/>
|
||||
|
||||
{values.productType === 'combo' && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="flex flex-row justify-between items-center mb-2">
|
||||
<P className="font-medium text-gray-600">Included Items</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const items = variant.comboItems || []
|
||||
items.push({ skuId: 0 })
|
||||
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
||||
}}
|
||||
className="bg-gray-200 px-2 py-0.5 rounded flex flex-row items-center text-gray-600 text-xs"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-gray-600" />
|
||||
<span className="ml-0.5">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
{variant.comboItems?.map((ci, cIndex) => (
|
||||
<div key={cIndex} className="flex flex-row items-center gap-2 mb-2">
|
||||
<div className="flex-1">
|
||||
<SearchableSelect
|
||||
label="SKU"
|
||||
value={ci.skuId}
|
||||
options={skuOptions.map((opt: { label: string; value: number }) => ({
|
||||
...opt,
|
||||
// Disable SKUs already picked in another row of this combo.
|
||||
disabled: (variant.comboItems || []).some(
|
||||
(item, idx) => idx !== cIndex && String(item.skuId) === String(opt.value)
|
||||
),
|
||||
}))}
|
||||
onValueChange={(val) => {
|
||||
const current = variant.comboItems || []
|
||||
const isDuplicate = current.some(
|
||||
(item, idx) => idx !== cIndex && String(item.skuId) === String(val)
|
||||
)
|
||||
if (isDuplicate) {
|
||||
window.alert('Duplicate: This product is already in the combo')
|
||||
return
|
||||
}
|
||||
setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, Number(val))
|
||||
}}
|
||||
placeholder="Select SKU"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const items = variant.comboItems.filter((_, i) => i !== cIndex)
|
||||
setFieldValue(`variants.${vIndex}.comboItems`, items)
|
||||
}}
|
||||
aria-label="Remove combo item"
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
push(defaultVariant())
|
||||
setVariantImages((prev) => [...prev, []])
|
||||
}}
|
||||
className="bg-blue-500 px-3 py-2 rounded-lg flex flex-row items-center justify-center mb-4 text-white font-semibold w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-1">Add Variant</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const validationErrors = await validateForm()
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
const variantsMessages = collectErrorMessages(validationErrors.variants)
|
||||
const allMessages = collectErrorMessages(validationErrors)
|
||||
const message = variantsMessages[0] || allMessages[0] || 'Please fix the highlighted fields'
|
||||
window.alert(`Check your form: ${String(message)}`)
|
||||
return
|
||||
}
|
||||
handleSubmit()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg items-center mt-4 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
{(() => {
|
||||
if (mode === 'edit') {
|
||||
return isLoading ? 'Saving...' : 'Save Changes'
|
||||
}
|
||||
return isLoading ? 'Creating...' : 'Create Product'
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
})
|
||||
|
||||
ProductForm.displayName = 'ProductForm'
|
||||
|
||||
export default ProductForm
|
||||
140
apps/admin-web/src/components/ProductGroupForm.tsx
Normal file
140
apps/admin-web/src/components/ProductGroupForm.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { type FC } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import { p as P, pInput as PInput } from 'web-components'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { X } from 'lucide-react'
|
||||
import type { AdminProductGroup as SharedAdminProductGroup } from '@packages/shared'
|
||||
|
||||
// Serialized product group (string date, loosely typed products) anchored to shared
|
||||
export type ProductGroup = Omit<SharedAdminProductGroup, 'createdAt' | 'products'> & {
|
||||
createdAt: string
|
||||
products: any[]
|
||||
}
|
||||
|
||||
interface ProductGroupFormProps {
|
||||
group?: ProductGroup | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const ProductGroupForm: FC<ProductGroupFormProps> = ({
|
||||
group,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
// Fetch products
|
||||
const { data: productsData } = trpc.common.product.getAllProductsSummary.useQuery()
|
||||
|
||||
const createGroup = trpc.admin.product.createGroup.useMutation()
|
||||
const updateGroup = trpc.admin.product.updateGroup.useMutation()
|
||||
|
||||
const isEditing = !!group
|
||||
|
||||
const products = productsData?.products || []
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
group_name: group?.groupName || '',
|
||||
description: group?.description || '',
|
||||
product_ids: group?.products?.map(p => p.id) || [],
|
||||
},
|
||||
validate: (values) => {
|
||||
const errors: {[key: string]: string} = {}
|
||||
|
||||
if (!values.group_name.trim()) {
|
||||
errors.group_name = 'Group name is required'
|
||||
}
|
||||
|
||||
return errors
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
if (isEditing) {
|
||||
await updateGroup.mutateAsync({
|
||||
id: group.id,
|
||||
group_name: values.group_name,
|
||||
description: values.description,
|
||||
product_ids: values.product_ids,
|
||||
})
|
||||
} else {
|
||||
await createGroup.mutateAsync({
|
||||
group_name: values.group_name,
|
||||
description: values.description,
|
||||
product_ids: values.product_ids,
|
||||
})
|
||||
}
|
||||
onSuccess()
|
||||
} catch (error: any) {
|
||||
window.alert(`Error: ${error.message || 'Failed to save group'}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between p-6 border-b border-gray-200">
|
||||
<P className="text-xl font-bold text-gray-900">
|
||||
{isEditing ? 'Edit Product Group' : 'Create Product Group'}
|
||||
</P>
|
||||
<button type="button" onClick={onClose} className="p-2" aria-label="Close">
|
||||
<X className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
{/* Group Name */}
|
||||
<PInput
|
||||
topLabel="Group Name *"
|
||||
placeholder="Enter group name"
|
||||
value={formik.values.group_name}
|
||||
onChange={(e) => formik.setFieldValue('group_name', e.target.value)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{formik.errors.group_name && (
|
||||
<P className="text-red-500 text-sm mt-1 mb-4">{formik.errors.group_name}</P>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<PInput
|
||||
topLabel="Description"
|
||||
placeholder="Enter description (optional)"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
value={formik.values.description}
|
||||
onChange={(e) => formik.setFieldValue('description', e.target.value)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{/* Products Selection */}
|
||||
<ProductsSelector
|
||||
value={formik.values.product_ids}
|
||||
onChange={(value) => formik.setFieldValue('product_ids', value as number[])}
|
||||
multiple={true}
|
||||
label="Products"
|
||||
placeholder="Select products"
|
||||
labelFormat={(product) => product.label}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-row gap-4">
|
||||
<div
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-200 rounded-lg py-4 items-center text-center text-gray-700 font-medium cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</div>
|
||||
<div
|
||||
onClick={() => formik.handleSubmit()}
|
||||
className="flex-1 bg-brand-600 rounded-lg py-4 items-center text-center text-white font-medium cursor-pointer"
|
||||
>
|
||||
{createGroup.isPending || updateGroup.isPending ? 'Saving...' : 'Save'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductGroupForm
|
||||
39
apps/admin-web/src/components/ProductListDialog.tsx
Normal file
39
apps/admin-web/src/components/ProductListDialog.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { FC } from 'react'
|
||||
import { BottomDialog, p as P } from 'web-components'
|
||||
import type { IdName } from '@packages/shared'
|
||||
|
||||
type VendorSnippetProduct = IdName
|
||||
|
||||
interface ProductListDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
products: VendorSnippetProduct[]
|
||||
}
|
||||
|
||||
export const ProductListDialog: FC<ProductListDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
products,
|
||||
}) => {
|
||||
return (
|
||||
<BottomDialog open={open} onClose={onClose}>
|
||||
<div className="pt-4 pb-8">
|
||||
<P className="text-lg font-bold mb-4 text-slate-900 block">
|
||||
Products ({products.length})
|
||||
</P>
|
||||
<div className="overflow-auto" style={{ maxHeight: 400 }}>
|
||||
{products.map((product, index) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`py-3 border-b border-slate-100 ${index === products.length - 1 ? 'border-b-0' : ''}`}
|
||||
>
|
||||
<P className="text-sm text-slate-800 font-medium">
|
||||
{product.name}
|
||||
</P>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
)
|
||||
}
|
||||
177
apps/admin-web/src/components/ProductsSelector.tsx
Normal file
177
apps/admin-web/src/components/ProductsSelector.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { SkuSummary as SharedSkuSummary } from '@packages/shared'
|
||||
import MultiSelect from './MultiSelect'
|
||||
import SearchableSelect from './SearchableSelect'
|
||||
|
||||
// Selector works with skus that may not have images resolved yet
|
||||
type SkuSummary = Omit<SharedSkuSummary, 'images'>
|
||||
|
||||
interface Group {
|
||||
id: number
|
||||
groupName: string
|
||||
products: { id: number }[]
|
||||
}
|
||||
|
||||
interface ProductsSelectorProps {
|
||||
value: number | number[]
|
||||
onChange: (value: number | number[]) => void
|
||||
multiple?: boolean
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: boolean
|
||||
isDisabled?: (product: SkuSummary) => boolean
|
||||
labelFormat?: (product: SkuSummary) => string
|
||||
groups?: Group[]
|
||||
selectedGroupIds?: number[]
|
||||
onGroupChange?: (groupIds: number[]) => void
|
||||
showGroups?: boolean
|
||||
}
|
||||
|
||||
export default function ProductsSelector({
|
||||
value,
|
||||
onChange,
|
||||
multiple = true,
|
||||
label = 'Select Products',
|
||||
placeholder = 'Select products',
|
||||
disabled = false,
|
||||
error = false,
|
||||
isDisabled,
|
||||
labelFormat,
|
||||
groups = [],
|
||||
showGroups = true,
|
||||
selectedGroupIds = [],
|
||||
onGroupChange,
|
||||
}: ProductsSelectorProps) {
|
||||
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
|
||||
const allSkus: SkuSummary[] = skusData?.skus || []
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Handle group selection changes
|
||||
const handleGroupChange = (newGroupIds: number[]) => {
|
||||
if (!onGroupChange) return
|
||||
|
||||
const previousGroupIds = selectedGroupIds
|
||||
|
||||
const addedGroups = newGroupIds.filter(id => !previousGroupIds.includes(id))
|
||||
const removedGroups = previousGroupIds.filter(id => !newGroupIds.includes(id))
|
||||
|
||||
let currentSkus = Array.isArray(value) ? [...value] : value ? [value] : []
|
||||
|
||||
const addedSkus = addedGroups.flatMap(groupId => {
|
||||
const group = groups.find(g => g.id === groupId)
|
||||
if (!group) return []
|
||||
const productIds = new Set(group.products.map(p => p.id))
|
||||
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
|
||||
})
|
||||
|
||||
const removedSkus = removedGroups.flatMap(groupId => {
|
||||
const group = groups.find(g => g.id === groupId)
|
||||
if (!group) return []
|
||||
const productIds = new Set(group.products.map(p => p.id))
|
||||
return allSkus.filter(s => productIds.has(s.productId)).map(s => s.id)
|
||||
})
|
||||
|
||||
currentSkus = [...new Set([...currentSkus, ...addedSkus])]
|
||||
currentSkus = currentSkus.filter(id => !removedSkus.includes(id))
|
||||
|
||||
onGroupChange(newGroupIds)
|
||||
if (multiple) {
|
||||
onChange(currentSkus.length > 0 ? currentSkus : [])
|
||||
} else {
|
||||
onChange(currentSkus.length > 0 ? currentSkus[0] : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter products based on search query
|
||||
const filteredSkus = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allSkus
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return allSkus.filter(sku =>
|
||||
sku.label.toLowerCase().includes(query) ||
|
||||
sku.productName.toLowerCase().includes(query)
|
||||
)
|
||||
}, [allSkus, searchQuery])
|
||||
|
||||
// Build dropdown options
|
||||
const productOptions = useMemo(() => {
|
||||
return filteredSkus.map((sku) => {
|
||||
const isFromGroup = selectedGroupIds.length > 0 && groups.some(group => {
|
||||
if (!selectedGroupIds.includes(group.id)) return false
|
||||
return group.products.some(p => p.id === sku.productId)
|
||||
})
|
||||
|
||||
const isProductDisabled = isDisabled ? isDisabled(sku) : false
|
||||
|
||||
const displayLabel = labelFormat
|
||||
? labelFormat(sku)
|
||||
: sku.label
|
||||
|
||||
return {
|
||||
label: `${displayLabel}${isFromGroup ? ' (from group)' : ''}`,
|
||||
value: sku.id.toString(),
|
||||
disabled: isProductDisabled,
|
||||
}
|
||||
})
|
||||
}, [filteredSkus, selectedGroupIds, groups, isDisabled, labelFormat])
|
||||
|
||||
// Build group options if groups are provided
|
||||
const groupOptions = useMemo(() => {
|
||||
return groups.map(group => ({
|
||||
label: group.groupName,
|
||||
value: group.id.toString(),
|
||||
}))
|
||||
}, [groups])
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{showGroups && groups.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<MultiSelect
|
||||
testID="product-groups-dropdown"
|
||||
label="Select Product Groups"
|
||||
options={groupOptions}
|
||||
value={selectedGroupIds.map(id => id.toString())}
|
||||
onValueChange={(selectedValue) => {
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
|
||||
const newGroupIds = selectedValues.map(v => parseInt(v as string))
|
||||
handleGroupChange(newGroupIds)
|
||||
}}
|
||||
placeholder="Select product groups (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multiple ? (
|
||||
<MultiSelect
|
||||
label={label}
|
||||
options={productOptions}
|
||||
value={Array.isArray(value) ? value.map(id => id.toString()) : []}
|
||||
onValueChange={(selectedValue) => {
|
||||
const selectedValues = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' ? [selectedValue] : []
|
||||
onChange(selectedValues.map(v => Number(v)))
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
onSearch={setSearchQuery}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
label={label}
|
||||
options={productOptions}
|
||||
value={value ? value.toString() : ''}
|
||||
onValueChange={(selectedValue) => {
|
||||
onChange(Number(selectedValue))
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
onSearch={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
apps/admin-web/src/components/SearchableSelect.tsx
Normal file
114
apps/admin-web/src/components/SearchableSelect.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { BottomDialog, SearchBar, p as P } from 'web-components'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface SearchableSelectOption {
|
||||
label: string
|
||||
value: string | number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
label: string
|
||||
options: SearchableSelectOption[]
|
||||
value: string | number
|
||||
onValueChange: (v: string | number) => void
|
||||
onSearch?: (q: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: boolean
|
||||
className?: string
|
||||
testID?: string
|
||||
// Tolerated for screen compatibility (RN BottomDropdown leftover) — ignored on web
|
||||
triggerComponent?: any
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
onSearch,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
error = false,
|
||||
className,
|
||||
testID,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const found = options.find((o) => String(o.value) === String(value))
|
||||
return found ? found.label : null
|
||||
}, [options, value])
|
||||
|
||||
const displayText = selectedLabel ?? placeholder ?? label
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return options
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q))
|
||||
}, [options, query])
|
||||
|
||||
const handleSelect = (optionValue: string | number) => {
|
||||
onValueChange(optionValue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} data-testid={testID}>
|
||||
<P className="mb-1 text-sm text-gray-500 font-medium">{label}</P>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
className={`flex w-full items-center justify-between rounded-md border bg-white px-3 py-2 text-sm text-left disabled:cursor-not-allowed disabled:opacity-50 ${error ? 'border-red-500' : 'border-gray-300'}`}
|
||||
>
|
||||
<span className={`truncate ${selectedLabel ? 'text-gray-800' : 'text-gray-400'}`}>
|
||||
{displayText}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-gray-400" />
|
||||
</button>
|
||||
<BottomDialog open={open} onClose={() => setOpen(false)}>
|
||||
<div className="p-4">
|
||||
<P className="text-lg font-semibold mb-4">{label}</P>
|
||||
<SearchBar
|
||||
placeholder="Search..."
|
||||
value={query}
|
||||
onChange={(q) => {
|
||||
setQuery(q)
|
||||
onSearch?.(q)
|
||||
}}
|
||||
onSearch={onSearch}
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: 320 }}>
|
||||
{filtered.map((option) => {
|
||||
const selected = String(option.value) === String(value)
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`flex flex-row items-center w-full p-3 border-b border-gray-100 text-left ${option.disabled ? 'opacity-50' : 'cursor-pointer hover:bg-gray-50'} ${selected ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<span className={`text-sm flex-1 ${selected ? 'font-semibold text-blue-700' : 'text-gray-800'}`}>
|
||||
{option.label}
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 text-blue-600" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<P className="text-center text-gray-500 text-sm p-4">No options found</P>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchableSelect
|
||||
229
apps/admin-web/src/components/SlotForm.tsx
Normal file
229
apps/admin-web/src/components/SlotForm.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import React from 'react'
|
||||
import type { SlotSnippetInput } from '@packages/shared'
|
||||
import { Formik, FieldArray } from 'formik'
|
||||
import DateTimeInput from './DateTimeInput'
|
||||
import { p as P, pInput as PInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
|
||||
// Snippet form row — single source in @packages/shared (complete payload)
|
||||
type VendorSnippet = SlotSnippetInput
|
||||
|
||||
interface SlotFormProps {
|
||||
onSlotAdded?: () => void
|
||||
initialDeliveryTime?: Date | null
|
||||
initialFreezeTime?: Date | null
|
||||
initialIsActive?: boolean
|
||||
slotId?: number
|
||||
initialProductIds?: number[]
|
||||
initialGroupIds?: number[]
|
||||
}
|
||||
|
||||
export default function SlotForm({
|
||||
onSlotAdded,
|
||||
initialDeliveryTime,
|
||||
initialFreezeTime,
|
||||
initialIsActive = true,
|
||||
slotId,
|
||||
initialProductIds = [],
|
||||
initialGroupIds = [],
|
||||
}: SlotFormProps) {
|
||||
const { data: slotData } = trpc.admin.slots.getSlotById.useQuery(
|
||||
{ id: slotId! },
|
||||
{ enabled: !!slotId }
|
||||
)
|
||||
|
||||
const vendorSnippetsFromSlot = (slotData?.slot?.vendorSnippets || []).map((snippet: any) => ({
|
||||
name: snippet.name || '',
|
||||
groupIds: snippet.groupIds || [],
|
||||
skuIds: snippet.skuIds || [],
|
||||
validTill: snippet.validTill || null,
|
||||
})) as VendorSnippet[]
|
||||
|
||||
const initialValues = {
|
||||
deliveryTime: initialDeliveryTime || (slotData?.slot?.deliveryTime ? new Date(slotData.slot.deliveryTime) : null),
|
||||
freezeTime: initialFreezeTime || (slotData?.slot?.freezeTime ? new Date(slotData.slot.freezeTime) : null),
|
||||
selectedGroupIds: initialGroupIds.length > 0 ? initialGroupIds : (slotData?.slot?.groupIds || []),
|
||||
selectedSkuIds: initialProductIds.length > 0 ? initialProductIds : (slotData?.slot?.products?.map((p: any) => p.id) || []),
|
||||
vendorSnippetList: vendorSnippetsFromSlot,
|
||||
}
|
||||
|
||||
const { mutate: createSlot, isPending: isCreating } = trpc.admin.slots.createSlot.useMutation()
|
||||
const { mutate: updateSlot, isPending: isUpdating } = trpc.admin.slots.updateSlot.useMutation()
|
||||
|
||||
const isEditMode = !!slotId
|
||||
const isPending = isCreating || isUpdating
|
||||
|
||||
const { data: groupsData } = trpc.admin.product.getGroups.useQuery()
|
||||
|
||||
const handleFormSubmit = (values: typeof initialValues) => {
|
||||
if (!values.deliveryTime || !values.freezeTime) {
|
||||
window.alert('Error: Please fill all fields')
|
||||
return
|
||||
}
|
||||
|
||||
if (values.freezeTime > values.deliveryTime) {
|
||||
window.alert('Error: Freeze time must be before or equal to delivery time')
|
||||
return
|
||||
}
|
||||
|
||||
const slotInputData = {
|
||||
deliveryTime: values.deliveryTime.toISOString(),
|
||||
freezeTime: values.freezeTime.toISOString(),
|
||||
isActive: initialIsActive,
|
||||
groupIds: values.selectedGroupIds,
|
||||
skuIds: values.selectedSkuIds,
|
||||
vendorSnippets: values.vendorSnippetList.map((snippet: VendorSnippet) => ({
|
||||
name: snippet.name,
|
||||
skuIds: snippet.skuIds,
|
||||
validTill: snippet.validTill ?? undefined,
|
||||
})),
|
||||
}
|
||||
|
||||
if (isEditMode && slotId) {
|
||||
updateSlot(
|
||||
{ id: slotId, ...slotInputData },
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Slot updated successfully!')
|
||||
onSlotAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.log({msg: JSON.stringify(error.message)})
|
||||
|
||||
window.alert(`Error: ${error.message || 'Failed to update slot'}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
createSlot(
|
||||
slotInputData,
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Slot created successfully!')
|
||||
onSlotAdded?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Error: ${error.message || 'Failed to create slot'}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
{({ handleSubmit, values, setFieldValue }) => {
|
||||
const mappedGroups = (groupsData?.groups || []).map(group => ({
|
||||
...group,
|
||||
products: group.products.map(product => ({
|
||||
...product,
|
||||
id: product.id,
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<P className="text-xl font-bold mb-6 text-center block">
|
||||
{isEditMode ? 'Edit Slot' : 'Create New Slot'}
|
||||
</P>
|
||||
|
||||
<div className="mb-4">
|
||||
<P className="text-lg font-semibold mb-2 block">Delivery Date & Time</P>
|
||||
<DateTimeInput
|
||||
value={values.deliveryTime}
|
||||
setValue={(value) => setFieldValue('deliveryTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<P className="text-lg font-semibold mb-2 block">Freeze Date & Time</P>
|
||||
<DateTimeInput
|
||||
value={values.freezeTime}
|
||||
setValue={(value) => setFieldValue('freezeTime', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<ProductsSelector
|
||||
value={values.selectedSkuIds}
|
||||
onChange={(newSkuIds) => setFieldValue('selectedSkuIds', newSkuIds)}
|
||||
groups={mappedGroups}
|
||||
selectedGroupIds={values.selectedGroupIds}
|
||||
onGroupChange={(newGroupIds) => setFieldValue('selectedGroupIds', newGroupIds)}
|
||||
label="Select Products"
|
||||
placeholder="Select products for this slot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vendor Snippets */}
|
||||
<FieldArray name="vendorSnippetList">
|
||||
{({ push, remove }) => (
|
||||
<div className="mb-4">
|
||||
<P className="text-lg font-semibold mb-4 block">Vendor Snippets</P>
|
||||
{values.vendorSnippetList.map((snippet: VendorSnippet, index: number) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<div className="mb-4">
|
||||
<PInput
|
||||
topLabel="Snippet Name *"
|
||||
placeholder="Enter snippet name"
|
||||
value={snippet.name}
|
||||
onChange={(e) => setFieldValue(`vendorSnippetList.${index}.name`, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<ProductsSelector
|
||||
value={snippet.skuIds || []}
|
||||
onChange={(newSkuIds) => setFieldValue(`vendorSnippetList.${index}.skuIds`, newSkuIds)}
|
||||
groups={mappedGroups.filter(group =>
|
||||
values.selectedGroupIds.includes(group.id)
|
||||
).map(group => ({
|
||||
...group,
|
||||
products: group.products.filter((prod: any) => values.selectedSkuIds.includes(prod.id))
|
||||
}))}
|
||||
selectedGroupIds={snippet.groupIds || []}
|
||||
onGroupChange={(newGroupIds) => setFieldValue(`vendorSnippetList.${index}.groupIds`, newGroupIds)}
|
||||
label="Select Products"
|
||||
placeholder="Select products for snippet"
|
||||
isDisabled={(sku) => !values.selectedSkuIds.includes(sku.id)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="bg-red-500 px-4 py-2 rounded-lg self-end text-white font-medium"
|
||||
>
|
||||
Remove Snippet
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => push({ name: '', groupIds: [], skuIds: [], validTill: '' })}
|
||||
className="bg-blue-500 px-4 py-3 rounded-lg items-center text-white font-medium w-full"
|
||||
>
|
||||
Add Vendor Snippet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</FieldArray>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-slot-button"
|
||||
aria-label="create-slot-button"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isPending}
|
||||
className={`${isPending ? 'bg-brand-100' : 'bg-brand-500'} p-3 rounded-lg items-center mt-6 pb-4 text-white text-base font-bold w-full disabled:opacity-70`}
|
||||
>
|
||||
{isPending ? (isEditMode ? 'Updating...' : 'Creating...') : (isEditMode ? 'Update Slot' : 'Create Slot')}
|
||||
</button>
|
||||
</div>
|
||||
)}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
156
apps/admin-web/src/components/SnippetMenu.tsx
Normal file
156
apps/admin-web/src/components/SnippetMenu.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, type FC } from 'react'
|
||||
import { p as P, BottomDialog } from 'web-components'
|
||||
import { SuccessToast } from '@/services/toaster'
|
||||
import type { VendorSnippet } from '@/types/vendor-snippets'
|
||||
import { Eye, Pencil, Link, Trash2, MoreVertical } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export interface SnippetMenuOption {
|
||||
title: string
|
||||
icon: 'eye' | 'edit' | 'link' | 'trash'
|
||||
onPress: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SnippetMenuProps {
|
||||
snippet: VendorSnippet
|
||||
onEdit: (snippet: VendorSnippet) => void
|
||||
onDelete: (id: number) => void
|
||||
onViewOrders: (snippetCode: string) => void
|
||||
triggerStyle?: any
|
||||
iconSize?: number
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
eye: Eye,
|
||||
edit: Pencil,
|
||||
link: Link,
|
||||
trash: Trash2,
|
||||
}
|
||||
|
||||
export const SnippetMenu: FC<SnippetMenuProps> = ({
|
||||
snippet,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOrders,
|
||||
triggerStyle = 'p-2 rounded-full bg-gray-50',
|
||||
iconSize = 16,
|
||||
iconColor = '#6B7280',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleViewOrders = () => {
|
||||
setIsOpen(false)
|
||||
onViewOrders(snippet.snippetCode)
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsOpen(false)
|
||||
onEdit(snippet)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsOpen(false)
|
||||
const confirmed = window.confirm(
|
||||
'Delete Snippet? Are you sure you want to delete this vendor snippet?'
|
||||
)
|
||||
if (confirmed) {
|
||||
onDelete(snippet.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
setIsOpen(false)
|
||||
if (!snippet.accessUrl) {
|
||||
window.alert('Error: No URL available to copy')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(snippet.accessUrl)
|
||||
SuccessToast('URL copied to clipboard')
|
||||
}
|
||||
|
||||
const options: SnippetMenuOption[] = [
|
||||
{
|
||||
title: 'View Orders',
|
||||
icon: 'eye',
|
||||
onPress: handleViewOrders,
|
||||
},
|
||||
{
|
||||
title: 'Edit',
|
||||
icon: 'edit',
|
||||
onPress: handleEdit,
|
||||
},
|
||||
{
|
||||
title: 'Copy URL',
|
||||
icon: 'link',
|
||||
onPress: handleCopyUrl,
|
||||
},
|
||||
{
|
||||
title: 'Delete',
|
||||
icon: 'trash',
|
||||
onPress: handleDelete,
|
||||
},
|
||||
]
|
||||
|
||||
const handleOptionPress = (option: SnippetMenuOption) => {
|
||||
if (option.disabled) return
|
||||
option.onPress()
|
||||
}
|
||||
|
||||
const triggerClassName = typeof triggerStyle === 'string' ? triggerStyle : 'p-2 rounded-full bg-gray-50'
|
||||
const triggerStyleObj: CSSProperties | undefined = typeof triggerStyle === 'object' ? triggerStyle : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Menu Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenMenu}
|
||||
className={triggerClassName}
|
||||
style={triggerStyleObj}
|
||||
aria-label="Snippet options"
|
||||
>
|
||||
<MoreVertical size={iconSize} color={iconColor} />
|
||||
</button>
|
||||
|
||||
{/* Menu Dialog */}
|
||||
<BottomDialog open={isOpen} onClose={handleCloseMenu}>
|
||||
<div className="p-6">
|
||||
<P className="text-lg font-bold text-gray-800 mb-6 block">
|
||||
Snippet Options
|
||||
</P>
|
||||
|
||||
{options.map((option, index) => {
|
||||
const Icon = iconMap[option.icon]
|
||||
return (
|
||||
<button
|
||||
key={`${snippet.id}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleOptionPress(option)}
|
||||
disabled={option.disabled}
|
||||
className={`flex flex-row items-center p-4 bg-gray-50 rounded-lg w-full text-left ${index < options.length - 1 ? 'mb-3' : ''} ${option.disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
color={option.disabled ? '#9CA3AF' : '#6B7280'}
|
||||
/>
|
||||
<P className={`font-medium ml-3 ${option.disabled ? 'text-gray-400' : 'text-gray-800'}`}>
|
||||
{option.title}
|
||||
</P>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
156
apps/admin-web/src/components/SnippetOrdersView.tsx
Normal file
156
apps/admin-web/src/components/SnippetOrdersView.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { Fragment, type FC } from 'react'
|
||||
import { p as P, AppContainer } from 'web-components'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
import type {
|
||||
AdminVendorSnippetOrderProduct,
|
||||
AdminVendorSnippetOrderSummary as SharedSnippetOrderSummary,
|
||||
} from '@packages/shared'
|
||||
|
||||
// Line item + order summary anchored to the shared vendor-order contract
|
||||
// (string totalAmount + any[] sequence at this view layer)
|
||||
type OrderProduct = Pick<
|
||||
AdminVendorSnippetOrderProduct,
|
||||
'productId' | 'productName' | 'quantity' | 'price' | 'unit' | 'subtotal'
|
||||
>
|
||||
|
||||
type SnippetOrder = Omit<SharedSnippetOrderSummary, 'totalAmount' | 'slotInfo' | 'products'> & {
|
||||
totalAmount: string
|
||||
slotInfo: {
|
||||
time: string
|
||||
sequence: any[]
|
||||
} | null
|
||||
products: OrderProduct[]
|
||||
}
|
||||
|
||||
interface SnippetOrdersViewProps {
|
||||
orders: SnippetOrder[]
|
||||
snippetCode: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const OrderCard: FC<{ order: SnippetOrder; index: number }> = ({ order, index }) => {
|
||||
const orderDate = new Date(order.orderDate)
|
||||
const slotTime = order.slotInfo ? new Date(order.slotInfo.time) : null
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
{/* Order Header */}
|
||||
<div className="flex flex-row justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
<P className="font-bold text-gray-800 text-lg block">{order.orderId}</P>
|
||||
<P className="text-sm text-gray-600 block">{order.customerName}</P>
|
||||
</div>
|
||||
<div className="items-end text-right">
|
||||
<P className="font-bold text-green-600 block">₹{order.totalAmount}</P>
|
||||
<P className="text-xs text-gray-500 block">
|
||||
{orderDate.toLocaleDateString()}
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slot Info */}
|
||||
{slotTime && (
|
||||
<div className="mb-3 p-2 bg-blue-50 rounded-lg">
|
||||
<P className="text-sm text-blue-800 font-medium">
|
||||
Delivery: {slotTime.toLocaleString()}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products */}
|
||||
<div className="mb-3">
|
||||
<P className="text-sm font-semibold text-gray-700 mb-2 block">Products:</P>
|
||||
{order.products.map((product, index) => {
|
||||
const isMatched = order.matchedProducts.includes(product.productId)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex flex-row justify-between items-center py-1 ${isMatched ? 'bg-yellow-50 px-2 rounded' : ''}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<P className={`text-sm block ${isMatched ? 'font-semibold text-yellow-800' : 'text-gray-700'}`}>
|
||||
{product.productName}
|
||||
{isMatched && ' ⭐'}
|
||||
</P>
|
||||
<P className="text-xs text-gray-500 block">
|
||||
{product.quantity} {product.unit} × ₹{product.price}
|
||||
</P>
|
||||
</div>
|
||||
<P className={`text-sm font-medium ${isMatched ? 'text-yellow-800' : 'text-gray-700'}`}>
|
||||
₹{product.subtotal.toFixed(2)}
|
||||
</P>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Matched Products Summary */}
|
||||
<div className="p-2 bg-yellow-50 rounded-lg">
|
||||
<P className="text-xs text-yellow-700">
|
||||
Matched {order.matchedProducts.length} product(s) from snippet "{order.snippetCode}"
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetOrdersView: FC<SnippetOrdersViewProps> = ({
|
||||
orders,
|
||||
snippetCode,
|
||||
onClose,
|
||||
}) => {
|
||||
const totalOrders = orders.length
|
||||
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount), 0)
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex-1">
|
||||
<P className="text-xl font-bold text-gray-800 block">Orders for "{snippetCode}"</P>
|
||||
<P className="text-gray-600 mt-1 block">
|
||||
{totalOrders} order{totalOrders !== 1 ? 's' : ''} • Total: ₹{totalRevenue.toFixed(2)}
|
||||
</P>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 text-lg"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="flex-1 overflow-auto px-6">
|
||||
{orders.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col justify-center items-center py-12">
|
||||
<ShoppingCart className="h-16 w-16 text-gray-300" />
|
||||
<P className="text-gray-500 text-lg font-semibold mt-4">No matching orders</P>
|
||||
<P className="text-gray-400 text-center mt-2">
|
||||
No orders found that match this snippet's criteria
|
||||
</P>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
{orders.map((order, index) => (
|
||||
<Fragment key={index}>
|
||||
<OrderCard order={order} index={index} />
|
||||
{index < orders.length - 1 && (
|
||||
<div className="h-px bg-slate-200 w-full" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetOrdersView
|
||||
197
apps/admin-web/src/components/StoreForm.tsx
Normal file
197
apps/admin-web/src/components/StoreForm.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useState, useEffect, useMemo, useRef, type ChangeEvent } from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { pInput as PInput, Dropdown, p as P, ImageUploader } from 'web-components'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
import type { CreateStoreInput } from '@packages/shared'
|
||||
|
||||
// Store form values — required description + product selection on top of CreateStoreInput
|
||||
export type StoreFormData = Omit<CreateStoreInput, 'description'> & {
|
||||
description: string
|
||||
products: number[]
|
||||
}
|
||||
|
||||
interface StoreFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
initialValues: StoreFormData
|
||||
onSubmit: (values: StoreFormData) => void
|
||||
isLoading: boolean
|
||||
storeId?: number
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string().required('Name is required'),
|
||||
description: Yup.string(),
|
||||
imageUrl: Yup.string(),
|
||||
owner: Yup.number().required('Owner is required'),
|
||||
products: Yup.array().of(Yup.number()),
|
||||
})
|
||||
|
||||
function StoreForm({ mode, initialValues, onSubmit, isLoading, storeId }: StoreFormProps) {
|
||||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery()
|
||||
const { data: productsData } = trpc.admin.product.getProducts.useQuery()
|
||||
|
||||
const [formInitialValues, setFormInitialValues] = useState<StoreFormData>(initialValues)
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob; mimeType: string }[]>([])
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// For edit mode, pre-select SKUs belonging to this store's products
|
||||
const initialSelectedProducts = useMemo(() => {
|
||||
if (mode !== 'edit' || !productsData?.products) return []
|
||||
return productsData.products
|
||||
.filter(p => p.storeId === storeId)
|
||||
.flatMap(p => (p.skus || []).map(sku => sku.id))
|
||||
}, [mode, productsData?.products, storeId])
|
||||
|
||||
useEffect(() => {
|
||||
setFormInitialValues({
|
||||
...initialValues,
|
||||
products: initialSelectedProducts,
|
||||
})
|
||||
}, [initialValues, initialSelectedProducts])
|
||||
|
||||
const existingImageUrls = useMemo(
|
||||
() => (formInitialValues.imageUrl ? [formInitialValues.imageUrl] : []),
|
||||
[formInitialValues.imageUrl]
|
||||
)
|
||||
|
||||
const staffOptions = staffData?.staff.map((staff: { id: number; name: string }) => ({
|
||||
label: staff.name,
|
||||
value: staff.id,
|
||||
})) || []
|
||||
|
||||
const { uploadSingle, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
const handleImagePick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) {
|
||||
setSelectedImages([])
|
||||
setDisplayImages([])
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
setSelectedImages([{ blob: file, mimeType: file.type || 'image/jpeg' }])
|
||||
setDisplayImages([{ uri: URL.createObjectURL(file) }])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleRemoveImage = (uri: string) => {
|
||||
const index = displayImages.findIndex(img => img.uri === uri)
|
||||
if (index !== -1) {
|
||||
const newDisplay = displayImages.filter((_, i) => i !== index)
|
||||
const newFiles = selectedImages.filter((_, i) => i !== index)
|
||||
setDisplayImages(newDisplay)
|
||||
setSelectedImages(newFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched }) => {
|
||||
const submit = async () => {
|
||||
try {
|
||||
let imageUrl: string | undefined
|
||||
|
||||
if (selectedImages.length > 0) {
|
||||
const { blob, mimeType } = selectedImages[0]
|
||||
const { presignedUrl } = await uploadSingle(blob, mimeType, 'store')
|
||||
imageUrl = presignedUrl
|
||||
}
|
||||
|
||||
onSubmit({ ...values, imageUrl })
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
window.alert('Error: Failed to upload image')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<PInput
|
||||
topLabel="Store Name"
|
||||
placeholder="Enter store name"
|
||||
value={values.name}
|
||||
onChange={handleChange('name')}
|
||||
error={!!(touched.name && errors.name)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<PInput
|
||||
topLabel="Description"
|
||||
placeholder="Enter store description"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
value={values.description}
|
||||
onChange={handleChange('description')}
|
||||
error={!!(touched.description && errors.description)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<P className="mb-1 text-sm text-gray-500 font-medium">Owner</P>
|
||||
<Dropdown
|
||||
label="Select owner"
|
||||
value={values.owner}
|
||||
options={staffOptions}
|
||||
onValueChange={(value) => setFieldValue('owner', Number(value))}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<ProductsSelector
|
||||
value={values.products || []}
|
||||
onChange={(value) => setFieldValue('products', value)}
|
||||
multiple={true}
|
||||
label="Products"
|
||||
placeholder="Select products"
|
||||
isDisabled={(sku) => sku.storeId !== null && sku.storeId !== storeId}
|
||||
labelFormat={(sku) => `${sku.productName} - ₹${sku.price}`}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<P className="text-sm font-bold text-gray-700 mb-3 uppercase tracking-wider block">Store Image</P>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={existingImageUrls}
|
||||
onAddImage={handleImagePick}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onRemoveExistingImage={() =>
|
||||
setFormInitialValues((prev) => ({
|
||||
...prev,
|
||||
imageUrl: undefined,
|
||||
}))
|
||||
}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={isLoading || isUploading}
|
||||
className={`px-4 py-2 rounded-lg shadow-lg items-center mt-2 text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading || isUploading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Store' : 'Update Store')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoreForm
|
||||
345
apps/admin-web/src/components/TagForm.tsx
Normal file
345
apps/admin-web/src/components/TagForm.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { useState, useEffect, forwardRef, useCallback, type CSSProperties } from 'react'
|
||||
import { Formik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { pInput as PInput, p as P, Checkbox, ImageUploaderNeo } from 'web-components'
|
||||
import type { ImageUploaderNeoItem, ImageUploaderNeoPayload } from 'web-components'
|
||||
import MultiSelect from './MultiSelect'
|
||||
import { GripVertical, X, Package, Image as ImageIcon } from 'lucide-react'
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import useFocusCallback from '@/lib/refresh-context'
|
||||
import type { IdName } from '@packages/shared'
|
||||
|
||||
type StoreOption = IdName
|
||||
|
||||
export interface TagFormData {
|
||||
tagName: string
|
||||
tagDescription: string
|
||||
isDashboardTag: boolean
|
||||
relatedStores: number[]
|
||||
productIds: number[]
|
||||
}
|
||||
|
||||
interface TagFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
initialValues: TagFormData
|
||||
existingImageUrl?: string
|
||||
onSubmit: (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => void
|
||||
isLoading: boolean
|
||||
stores?: StoreOption[]
|
||||
}
|
||||
|
||||
interface SelectedProduct {
|
||||
skuId: number
|
||||
productId: number
|
||||
label: string
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
const itemStyle: CSSProperties = {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
borderStyle: 'solid',
|
||||
padding: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)',
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
}
|
||||
|
||||
function SortableProductRow({ item, onRemove }: { item: SelectedProduct; onRemove: (productId: number) => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.productId })
|
||||
const style: CSSProperties = {
|
||||
...itemStyle,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging
|
||||
? { borderColor: '#3b82f6', boxShadow: '0 4px 8px rgba(59, 130, 246, 0.3)' }
|
||||
: {}),
|
||||
}
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
className="mr-2 p-0.5 cursor-grab active:cursor-grabbing touch-none"
|
||||
>
|
||||
<GripVertical size={24} color={isDragging ? '#3b82f6' : '#9ca3af'} />
|
||||
</button>
|
||||
{item.imageUrl ? (
|
||||
<img src={item.imageUrl} alt={item.label} style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ImageIcon size={24} color="#9ca3af" />
|
||||
</div>
|
||||
)}
|
||||
<P className="flex-1 text-sm text-gray-900 font-medium mr-1 truncate">
|
||||
{item.label}
|
||||
</P>
|
||||
<button type="button" onClick={() => onRemove(item.productId)} className="p-0.5" aria-label={`Remove ${item.label}`}>
|
||||
<X size={18} color="#9ca3af" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TagForm = forwardRef<any, TagFormProps>(({
|
||||
mode,
|
||||
initialValues,
|
||||
existingImageUrl: existingImageUrlRaw,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
stores: storesRaw,
|
||||
}, ref) => {
|
||||
const [images, setImages] = useState<ImageUploaderNeoItem[]>([])
|
||||
const [removedExisting, setRemovedExisting] = useState(false)
|
||||
const [isDashboardTagChecked, setIsDashboardTagChecked] = useState<boolean>(Boolean(initialValues.isDashboardTag))
|
||||
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>([])
|
||||
const sensors = useSensors(useSensor(PointerSensor))
|
||||
|
||||
const existingImageUrl = existingImageUrlRaw || ''
|
||||
const stores = storesRaw || []
|
||||
|
||||
const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery()
|
||||
const allSkus: SelectedProduct[] = (skusData?.skus || []).map((sku: any) => ({
|
||||
skuId: sku.id,
|
||||
productId: sku.productId,
|
||||
label: sku.label,
|
||||
imageUrl: sku.images?.[0] || null,
|
||||
}))
|
||||
|
||||
// Build the ordered list from initialValues.productIds (product ids, in sortOrder).
|
||||
useEffect(() => {
|
||||
const ordered: SelectedProduct[] = []
|
||||
const skuMap = new Map<number, SelectedProduct>()
|
||||
for (const sku of allSkus) {
|
||||
skuMap.set(sku.productId, sku)
|
||||
}
|
||||
for (const productId of initialValues.productIds || []) {
|
||||
const sku = skuMap.get(productId)
|
||||
if (sku) ordered.push(sku)
|
||||
else ordered.push({ skuId: 0, productId, label: `Product #${productId}`, imageUrl: null })
|
||||
}
|
||||
setSelectedProducts(ordered)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValues.productIds, skusData])
|
||||
|
||||
// Update checkbox when initial values change
|
||||
useEffect(() => {
|
||||
setIsDashboardTagChecked(Boolean(initialValues.isDashboardTag))
|
||||
if (existingImageUrl) {
|
||||
setImages([{ imgUrl: existingImageUrl, mimeType: null }])
|
||||
} else {
|
||||
setImages([])
|
||||
}
|
||||
setRemovedExisting(false)
|
||||
}, [existingImageUrlRaw, initialValues.isDashboardTag])
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
tagName: Yup.string()
|
||||
.required('Tag name is required')
|
||||
.min(1, 'Tag name must be at least 1 character')
|
||||
.max(100, 'Tag name must be less than 100 characters'),
|
||||
tagDescription: Yup.string()
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
})
|
||||
|
||||
// When a product is picked in the selector, add it to the ordered list (if not already present).
|
||||
const handleProductSelect = (value: number | number[]) => {
|
||||
const skuIds = Array.isArray(value) ? value : [value]
|
||||
setSelectedProducts((prev) => {
|
||||
const next = [...prev]
|
||||
for (const skuId of skuIds) {
|
||||
if (next.some((prod) => prod.skuId === skuId)) continue
|
||||
const sku = allSkus.find((s) => s.skuId === skuId)
|
||||
if (sku) next.push(sku)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
setSelectedProducts((prev) => {
|
||||
const oldIndex = prev.findIndex((prod) => prod.productId === active.id)
|
||||
const newIndex = prev.findIndex((prod) => prod.productId === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleRemoveProduct = useCallback((productId: number) => {
|
||||
setSelectedProducts((prev) => prev.filter((prod) => prod.productId !== productId))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={(values) => onSubmit({ ...values, productIds: selectedProducts.map((prod) => prod.productId) }, images, removedExisting)}
|
||||
enableReinitialize
|
||||
>
|
||||
{({ handleChange, handleSubmit, values, setFieldValue, errors, touched, resetForm }) => {
|
||||
// Clear form when screen comes into focus (create mode only — edit keeps its loaded products)
|
||||
const clearForm = useCallback(() => {
|
||||
setImages([])
|
||||
setRemovedExisting(false)
|
||||
setIsDashboardTagChecked(false)
|
||||
if (mode === 'create') {
|
||||
setSelectedProducts([])
|
||||
}
|
||||
resetForm()
|
||||
}, [resetForm, mode])
|
||||
|
||||
useFocusCallback(clearForm)
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<PInput
|
||||
topLabel="Tag Name"
|
||||
placeholder="Enter tag name"
|
||||
value={values.tagName}
|
||||
onChange={handleChange('tagName')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{errors.tagName && touched.tagName ? (
|
||||
<P className="text-red-500 text-sm -mt-2 mb-4 block">{errors.tagName}</P>
|
||||
) : null}
|
||||
|
||||
<PInput
|
||||
topLabel="Description (Optional)"
|
||||
placeholder="Enter tag description"
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
value={values.tagDescription}
|
||||
onChange={handleChange('tagDescription')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{errors.tagDescription && touched.tagDescription ? (
|
||||
<P className="text-red-500 text-sm -mt-2 mb-4 block">{errors.tagDescription}</P>
|
||||
) : null}
|
||||
|
||||
{/* Image Upload Section */}
|
||||
<div className="mb-6">
|
||||
<P className="text-lg font-bold mb-2 text-gray-800 block">
|
||||
Tag Image {mode === 'edit' ? '(Upload new to replace)' : '(Optional)'}
|
||||
</P>
|
||||
|
||||
<ImageUploaderNeo
|
||||
images={images}
|
||||
onImageAdd={(payload: ImageUploaderNeoPayload[]) => {
|
||||
setImages((prev) => [...prev, ...payload.map((img) => ({
|
||||
imgUrl: img.url,
|
||||
mimeType: img.mimeType,
|
||||
}))])
|
||||
}}
|
||||
onImageRemove={(payload) => {
|
||||
if (payload.mimeType === null) {
|
||||
setRemovedExisting(true)
|
||||
}
|
||||
setImages((prev) => prev.filter((item) => item.imgUrl !== payload.url))
|
||||
}}
|
||||
allowMultiple={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Tag Checkbox */}
|
||||
<div className="flex flex-row items-center mb-6">
|
||||
<Checkbox
|
||||
checked={isDashboardTagChecked}
|
||||
onPress={() => {
|
||||
const newValue = !isDashboardTagChecked
|
||||
setIsDashboardTagChecked(newValue)
|
||||
setFieldValue('isDashboardTag', newValue)
|
||||
}}
|
||||
/>
|
||||
<P className="ml-3 text-gray-800">Mark as Dashboard Tag</P>
|
||||
</div>
|
||||
|
||||
{/* Related Stores Dropdown */}
|
||||
<div className="mb-6">
|
||||
<P className="text-lg font-bold mb-2 text-gray-800 block">
|
||||
Related Stores
|
||||
</P>
|
||||
<MultiSelect
|
||||
label="Select Related Stores"
|
||||
placeholder="Select stores..."
|
||||
value={values.relatedStores.map(id => id.toString())}
|
||||
options={stores.map(store => ({
|
||||
label: store.name,
|
||||
value: store.id.toString(),
|
||||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const numericValues = (selectedValues as string[]).map(v => parseInt(v))
|
||||
setFieldValue('relatedStores', numericValues)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Products Section: selector + reorderable list */}
|
||||
<div className="mb-6">
|
||||
<P className="text-lg font-bold mb-2 text-gray-800 block">
|
||||
Products
|
||||
</P>
|
||||
<ProductsSelector
|
||||
value={[]}
|
||||
onChange={handleProductSelect}
|
||||
multiple
|
||||
label="Select Products"
|
||||
placeholder="Search and select products..."
|
||||
showGroups={false}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="bg-blue-50 px-4 py-2 mb-2 rounded-lg">
|
||||
<P className="text-blue-700 text-xs text-center block">
|
||||
Drag to reorder • {selectedProducts.length} items
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
{selectedProducts.length > 0 ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={selectedProducts.map((prod) => prod.productId)} strategy={verticalListSortingStrategy}>
|
||||
{selectedProducts.map((prod) => (
|
||||
<SortableProductRow key={prod.productId} item={prod} onRemove={handleRemoveProduct} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="bg-gray-50 p-6 rounded-xl border border-gray-200 border-dashed flex flex-col items-center justify-center">
|
||||
<Package size={32} color="#9CA3AF" />
|
||||
<P className="text-gray-500 mt-2 text-sm">No products selected yet</P>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isLoading}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg items-center text-white text-lg font-bold w-full disabled:opacity-70 ${isLoading ? 'bg-gray-400' : 'bg-blue-500'}`}
|
||||
>
|
||||
{isLoading ? (mode === 'create' ? 'Creating...' : 'Updating...') : (mode === 'create' ? 'Create Tag' : 'Update Tag')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
)
|
||||
})
|
||||
|
||||
TagForm.displayName = 'TagForm'
|
||||
|
||||
export default TagForm
|
||||
122
apps/admin-web/src/components/TagMenu.tsx
Normal file
122
apps/admin-web/src/components/TagMenu.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useState, type FC } from 'react'
|
||||
import { p as P, BottomDialog } from 'web-components'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { Pencil, Trash2, MoreVertical } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export interface TagMenuProps {
|
||||
tagId: number
|
||||
onDeleteSuccess?: () => void
|
||||
triggerStyle?: any
|
||||
iconSize?: number
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
export const TagMenu: FC<TagMenuProps> = ({
|
||||
tagId,
|
||||
onDeleteSuccess,
|
||||
triggerStyle = 'p-2 rounded-full bg-gray-50',
|
||||
iconSize = 16,
|
||||
iconColor = '#6B7280',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const deleteTag = trpc.admin.product.deleteProductTag.useMutation()
|
||||
|
||||
const handleOpenMenu = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseMenu = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleEditTag = () => {
|
||||
setIsOpen(false)
|
||||
navigate({ to: '/dashboard/product-tags/edit', search: { tagId: String(tagId) } })
|
||||
}
|
||||
|
||||
const handleDeleteTag = () => {
|
||||
setIsOpen(false)
|
||||
const confirmed = window.confirm(
|
||||
'Delete Tag? Are you sure you want to delete this tag? This action cannot be undone.'
|
||||
)
|
||||
if (confirmed) {
|
||||
performDelete()
|
||||
}
|
||||
}
|
||||
|
||||
const performDelete = () => {
|
||||
deleteTag.mutate({ id: tagId }, {
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Tag deleted successfully')
|
||||
onDeleteSuccess?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorMessage = error.message || 'Failed to delete tag'
|
||||
window.alert(`Error: ${errorMessage}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const options = [
|
||||
{
|
||||
title: 'Edit Tag',
|
||||
icon: Pencil,
|
||||
onPress: handleEditTag,
|
||||
},
|
||||
{
|
||||
title: 'Delete Tag',
|
||||
icon: Trash2,
|
||||
onPress: handleDeleteTag,
|
||||
},
|
||||
]
|
||||
|
||||
const triggerClassName = typeof triggerStyle === 'string' ? triggerStyle : 'p-2 rounded-full bg-gray-50'
|
||||
const triggerStyleObj: CSSProperties | undefined = typeof triggerStyle === 'object' ? triggerStyle : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Menu Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenMenu}
|
||||
className={triggerClassName}
|
||||
style={triggerStyleObj}
|
||||
aria-label="Tag options"
|
||||
>
|
||||
<MoreVertical size={iconSize} color={iconColor} />
|
||||
</button>
|
||||
|
||||
{/* Menu Dialog */}
|
||||
<BottomDialog open={isOpen} onClose={handleCloseMenu}>
|
||||
<div className="p-6">
|
||||
<P className="text-lg font-bold text-gray-800 mb-6 block">
|
||||
Tag Options
|
||||
</P>
|
||||
|
||||
{options.map((option, index) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<button
|
||||
key={`${tagId}-${index}`}
|
||||
type="button"
|
||||
onClick={option.onPress}
|
||||
className={`flex flex-row items-center p-4 bg-gray-50 rounded-lg w-full text-left ${index < options.length - 1 ? 'mb-3' : ''}`}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
color="#6B7280"
|
||||
/>
|
||||
<P className="text-gray-800 font-medium ml-3">
|
||||
{option.title}
|
||||
</P>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
207
apps/admin-web/src/components/UserIncidentsView.tsx
Normal file
207
apps/admin-web/src/components/UserIncidentsView.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React, { useState } from 'react'
|
||||
import { p as P, BottomDialog, pInput as PInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { TriangleAlert, Info, Plus, Calendar, User, ShoppingCart, CircleCheck } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
function UserIncidentDialog({
|
||||
userId,
|
||||
orderId,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
userId: number
|
||||
orderId: number | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [adminComment, setAdminComment] = useState('')
|
||||
const [negativityScore, setNegativityScore] = useState('')
|
||||
|
||||
const addIncidentMutation = trpc.admin.user.addUserIncident.useMutation({
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Incident added successfully')
|
||||
setAdminComment('')
|
||||
setNegativityScore('')
|
||||
onClose()
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Error: ${error.message || 'Failed to add incident'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddIncident = () => {
|
||||
const score = negativityScore ? parseInt(negativityScore) : undefined
|
||||
|
||||
if (!adminComment.trim() && !negativityScore) {
|
||||
window.alert('Error: Please enter a comment or negativity score')
|
||||
return
|
||||
}
|
||||
|
||||
addIncidentMutation.mutate({
|
||||
userId,
|
||||
orderId: orderId || undefined,
|
||||
adminComment: adminComment || undefined,
|
||||
negativityScore: score,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomDialog open={open} onClose={onClose}>
|
||||
<div className="p-6">
|
||||
<div className="items-center mb-6 flex flex-col">
|
||||
<div className="w-12 h-12 bg-amber-100 rounded-full items-center justify-center mb-3 flex">
|
||||
<TriangleAlert className="h-6 w-6 text-amber-600" />
|
||||
</div>
|
||||
<P className="text-xl font-bold text-gray-900 text-center">
|
||||
Add User Incident
|
||||
</P>
|
||||
<P className="text-gray-500 text-center mt-2 text-sm leading-5">
|
||||
Record an incident for this user. This will be visible in their profile.
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<PInput
|
||||
topLabel="Admin Comment"
|
||||
value={adminComment}
|
||||
onChange={(e) => setAdminComment(e.target.value)}
|
||||
placeholder="Enter details about the incident..."
|
||||
multiline
|
||||
className="h-24"
|
||||
/>
|
||||
|
||||
<PInput
|
||||
topLabel="Negativity Score (Optional)"
|
||||
value={negativityScore}
|
||||
onChange={(e) => setNegativityScore(e.target.value)}
|
||||
placeholder="0"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="bg-amber-50 p-4 rounded-xl border border-amber-100 mb-6 mt-4 flex flex-row items-start">
|
||||
<Info className="h-5 w-5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<P className="text-sm text-amber-800 ml-2 flex-1 leading-5">
|
||||
Higher negativity scores indicate more serious incidents (e.g., repeated cancellations, abusive behavior).
|
||||
</P>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 bg-gray-100 py-3.5 rounded-xl items-center text-gray-700 font-bold"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddIncident}
|
||||
disabled={addIncidentMutation.isPending}
|
||||
className={`flex-1 bg-amber-500 py-3.5 rounded-xl items-center shadow-sm text-white font-bold disabled:opacity-50 ${addIncidentMutation.isPending ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{addIncidentMutation.isPending ? 'Adding...' : 'Add Incident'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function UserIncidentsView({ userId, orderId }: { userId: number; orderId: number | null }) {
|
||||
const [incidentDialogOpen, setIncidentDialogOpen] = useState(false)
|
||||
|
||||
const { data: incidentsData, refetch: refetchIncidents } = trpc.admin.user.getUserIncidents.useQuery(
|
||||
{ userId },
|
||||
{ enabled: !!userId }
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-amber-50 p-5 rounded-2xl shadow-sm mb-4 border border-amber-100">
|
||||
<div className="flex flex-row items-center justify-between mb-4">
|
||||
<P className="text-base font-bold text-amber-900">
|
||||
User Incidents
|
||||
</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncidentDialogOpen(true)}
|
||||
className="flex flex-row items-center bg-amber-200 px-3 py-1.5 rounded-lg"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-amber-700" />
|
||||
<P className="text-xs font-bold text-amber-800 ml-1">Add Incident</P>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{incidentsData?.incidents && incidentsData.incidents.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{incidentsData.incidents.map((incident: any, index: number) => (
|
||||
<div
|
||||
key={incident.id}
|
||||
className={`bg-white p-4 rounded-xl border border-amber-200 ${index === incidentsData.incidents.length - 1 ? 'mb-0' : 'mb-3'}`}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-start mb-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<Calendar className="h-3.5 w-3.5 text-gray-500" />
|
||||
<P className="text-xs text-gray-600 ml-1">
|
||||
{dayjs(incident.dateAdded).format('MMM DD, YYYY • h:mm A')}
|
||||
</P>
|
||||
</div>
|
||||
{incident.negativityScore && (
|
||||
<div className="px-2 py-1 bg-red-100 rounded-md">
|
||||
<P className="text-xs font-bold text-red-700">
|
||||
Score: {incident.negativityScore}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{incident.adminComment && (
|
||||
<div className="mt-2">
|
||||
<P className="text-sm text-gray-900 leading-5 block">
|
||||
{incident.adminComment}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center mt-2 pt-2 border-t border-gray-100">
|
||||
<User className="h-3 w-3 text-gray-400" />
|
||||
<P className="text-xs text-gray-500 ml-1">
|
||||
Added by {incident.addedBy}
|
||||
</P>
|
||||
{incident.orderId && (
|
||||
<>
|
||||
<ShoppingCart className="h-3 w-3 text-gray-400 ml-3" />
|
||||
<P className="text-xs text-gray-500 ml-1">
|
||||
Order #{incident.orderId}
|
||||
</P>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<CircleCheck className="h-8 w-8 text-amber-600" />
|
||||
<P className="text-sm text-amber-700 mt-2">
|
||||
No incidents recorded for this user
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserIncidentDialog
|
||||
orderId={orderId}
|
||||
userId={userId}
|
||||
open={incidentDialogOpen}
|
||||
onClose={() => setIncidentDialogOpen(false)}
|
||||
onSuccess={refetchIncidents}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
225
apps/admin-web/src/components/VendorSnippetForm.tsx
Normal file
225
apps/admin-web/src/components/VendorSnippetForm.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { useEffect, type FC } from 'react'
|
||||
import { useFormik } from 'formik'
|
||||
import { p as P, Dropdown, pInput as PInput, Checkbox } from 'web-components'
|
||||
import ProductsSelector from './ProductsSelector'
|
||||
import DateInput from './DateInput'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { VendorSnippetForm as VendorSnippetFormType } from '@/types/vendor-snippets'
|
||||
|
||||
interface VendorSnippetFormProps {
|
||||
snippet?: VendorSnippetFormType | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
showIsPermanentCheckbox?: boolean
|
||||
}
|
||||
|
||||
const VendorSnippetForm: FC<VendorSnippetFormProps> = ({
|
||||
snippet,
|
||||
onClose,
|
||||
onSuccess,
|
||||
showIsPermanentCheckbox = false,
|
||||
}) => {
|
||||
|
||||
// Fetch slots
|
||||
const { data: slotsData } = trpc.user.slots.getSlots.useQuery()
|
||||
|
||||
const createSnippet = trpc.admin.vendorSnippets.create.useMutation()
|
||||
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation()
|
||||
|
||||
const isEditing = !!snippet
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
snippetCode: snippet?.snippetCode || '',
|
||||
slotId: snippet?.slotId?.toString() || '',
|
||||
isPermanent: snippet?.isPermanent || false,
|
||||
skuIds: snippet?.skuIds?.map(id => id.toString()) || [],
|
||||
validTill: snippet?.validTill ? new Date(snippet.validTill) : null,
|
||||
},
|
||||
validate: (values) => {
|
||||
const errors: {[key: string]: string} = {}
|
||||
|
||||
if (!values.snippetCode.trim()) {
|
||||
errors.snippetCode = 'Snippet code is required'
|
||||
}
|
||||
|
||||
// Validate snippet code only contains alphanumeric characters and underscore
|
||||
const snippetCodeRegex = /^[a-zA-Z0-9_]+$/
|
||||
if (values.snippetCode && !snippetCodeRegex.test(values.snippetCode)) {
|
||||
errors.snippetCode = 'Snippet code can only contain letters, numbers, and underscores'
|
||||
}
|
||||
|
||||
// Only require slotId if isPermanent is false
|
||||
if (!values.isPermanent && !values.slotId) {
|
||||
errors.slotId = 'Slot selection is required'
|
||||
}
|
||||
|
||||
if (values.skuIds.length === 0) {
|
||||
errors.skuIds = 'At least one product must be selected'
|
||||
}
|
||||
|
||||
return errors
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
|
||||
const submitData = {
|
||||
snippetCode: values.snippetCode,
|
||||
slotId: values.isPermanent ? undefined : parseInt(values.slotId || '0'),
|
||||
isPermanent: values.isPermanent,
|
||||
skuIds: values.skuIds.map(id => parseInt(id)),
|
||||
validTill: values.validTill ? values.validTill.toISOString() : undefined,
|
||||
}
|
||||
|
||||
if (isEditing && snippet) {
|
||||
await updateSnippet.mutateAsync({
|
||||
id: snippet.id,
|
||||
updates: submitData,
|
||||
})
|
||||
window.alert('Success: Vendor snippet updated successfully')
|
||||
} else {
|
||||
await createSnippet.mutateAsync(submitData)
|
||||
window.alert('Success: Vendor snippet created successfully')
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
onClose()
|
||||
} catch (error: any) {
|
||||
window.alert(`Error: ${error.message || 'Failed to save vendor snippet'}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Generate unique snippet code if creating new (only on mount)
|
||||
useEffect(() => {
|
||||
if (!isEditing && !formik.values.snippetCode) {
|
||||
const timestamp = Date.now()
|
||||
const random = Math.random().toString(36).substring(2, 8)
|
||||
formik.setFieldValue('snippetCode', `VS_${timestamp}_${random}`)
|
||||
}
|
||||
}, [isEditing]) // Removed formik.values.snippetCode from deps
|
||||
|
||||
const slotOptions = slotsData?.slots.map(slot => ({
|
||||
label: new Date(slot.deliveryTime).toLocaleString(),
|
||||
value: slot.id.toString(),
|
||||
})) || []
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<P className="text-xl font-bold text-gray-800">
|
||||
{isEditing ? 'Edit Vendor Snippet' : 'Create Vendor Snippet'}
|
||||
</P>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 text-lg"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-6">
|
||||
<div className="py-6 space-y-6">
|
||||
{/* Snippet Code */}
|
||||
<div>
|
||||
<PInput
|
||||
topLabel="Snippet Code"
|
||||
value={formik.values.snippetCode}
|
||||
onChange={formik.handleChange('snippetCode')}
|
||||
placeholder="Enter snippet code"
|
||||
error={!!formik.errors.snippetCode && !!formik.touched.snippetCode}
|
||||
/>
|
||||
{formik.errors.snippetCode && formik.touched.snippetCode && (
|
||||
<P className="text-red-500 text-sm mt-1">{formik.errors.snippetCode}</P>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Is Permanent Checkbox */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
|
||||
className="flex flex-row items-center mb-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={formik.values.isPermanent}
|
||||
onPress={() => formik.setFieldValue('isPermanent', !formik.values.isPermanent)}
|
||||
/>
|
||||
<P className="text-gray-700 font-medium ml-3">Is Permanent?</P>
|
||||
</button>
|
||||
<P className="text-sm text-gray-500 block">
|
||||
Check if this snippet is permanent and not tied to a specific delivery slot
|
||||
</P>
|
||||
</div>
|
||||
|
||||
{/* Slot Selection - Only show if not permanent */}
|
||||
{!formik.values.isPermanent && (
|
||||
<div>
|
||||
<P className="text-gray-700 font-medium mb-2 block">Delivery Slot</P>
|
||||
<Dropdown
|
||||
label="Select Slot"
|
||||
value={formik.values.slotId}
|
||||
options={slotOptions}
|
||||
onValueChange={(value) => formik.setFieldValue('slotId', String(value))}
|
||||
className="w-full"
|
||||
/>
|
||||
{formik.errors.slotId && formik.touched.slotId && (
|
||||
<P className="text-red-500 text-sm mt-1">{formik.errors.slotId}</P>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Selection */}
|
||||
<div>
|
||||
<ProductsSelector
|
||||
value={formik.values.skuIds.map(id => parseInt(id))}
|
||||
onChange={(selectedProductIds) => formik.setFieldValue('skuIds', (selectedProductIds as number[]).map(id => id.toString()))}
|
||||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Select products"
|
||||
labelFormat={(product) => product.label}
|
||||
/>
|
||||
{formik.errors.skuIds && formik.touched.skuIds && (
|
||||
<P className="text-red-500 text-sm mt-1">{formik.errors.skuIds}</P>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Valid Till Date */}
|
||||
<div>
|
||||
<P className="text-gray-700 font-medium mb-2 block">Valid Till (Optional)</P>
|
||||
<DateInput
|
||||
value={formik.values.validTill}
|
||||
setValue={(date) => formik.setFieldValue('validTill', date)}
|
||||
placeholder="Select expiry date"
|
||||
showLabel={false}
|
||||
/>
|
||||
<P className="text-sm text-gray-500 mt-1 block">
|
||||
Leave empty for no expiry
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => formik.handleSubmit()}
|
||||
disabled={formik.isSubmitting}
|
||||
className={`bg-blue-500 py-3 rounded-lg w-full text-white text-center font-semibold disabled:opacity-50 ${formik.isSubmitting ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{formik.isSubmitting
|
||||
? 'Saving...'
|
||||
: isEditing ? 'Update Snippet' : 'Create Snippet'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VendorSnippetForm
|
||||
112
apps/admin-web/src/components/context/staff-auth-context.tsx
Normal file
112
apps/admin-web/src/components/context/staff-auth-context.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import queryClient from '../../lib/query-client'
|
||||
import { EventBus, FORCE_LOGOUT_EVENT } from '../../lib/event-bus'
|
||||
import { trpc } from '../../lib/trpc-client'
|
||||
import { saveJWT, getJWT, deleteJWT } from '../../hooks/useJWT'
|
||||
import type { IdName } from '@packages/shared'
|
||||
|
||||
type Staff = IdName
|
||||
|
||||
interface StaffAuthContextType {
|
||||
isLoggedIn: boolean
|
||||
isLoading: boolean
|
||||
staff: Staff | null
|
||||
login: (name: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
isLoggingIn: boolean
|
||||
loginError: string | null
|
||||
}
|
||||
|
||||
const StaffAuthContext = createContext<StaffAuthContextType | undefined>(undefined)
|
||||
|
||||
export const StaffAuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [staff, setStaff] = useState<Staff | null>(null)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
|
||||
const loginMutation = trpc.admin.staffUser.login.useMutation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = await getJWT()
|
||||
if (token) {
|
||||
setIsLoggedIn(true)
|
||||
} else {
|
||||
setIsLoggedIn(false)
|
||||
if (!location.pathname.includes('login')) {
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const login = async (name: string, password: string) => {
|
||||
setLoginError(null)
|
||||
loginMutation.mutate(
|
||||
{ name, password },
|
||||
{
|
||||
onSuccess: async (data) => {
|
||||
await saveJWT(data.token)
|
||||
setStaff(data.staff)
|
||||
setIsLoggedIn(true)
|
||||
navigate({ to: '/dashboard' })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoginError(error.message || 'Login failed')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await deleteJWT()
|
||||
setIsLoggedIn(false)
|
||||
setStaff(null)
|
||||
queryClient.clear()
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = EventBus.addListener(FORCE_LOGOUT_EVENT, () => {
|
||||
console.log('force logout event received')
|
||||
logout()
|
||||
})
|
||||
|
||||
return () => {
|
||||
subscription.remove()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StaffAuthContext.Provider
|
||||
value={{
|
||||
isLoggedIn,
|
||||
isLoading,
|
||||
staff,
|
||||
login,
|
||||
logout,
|
||||
isLoggingIn: loginMutation.isPending,
|
||||
loginError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StaffAuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useStaffAuth = () => {
|
||||
const context = useContext(StaffAuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useStaffAuth must be used within a StaffAuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
15
apps/admin-web/src/hooks/useJWT.ts
Normal file
15
apps/admin-web/src/hooks/useJWT.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { StorageService } from 'web-components'
|
||||
|
||||
export const JWT_KEY = 'jwt_token'
|
||||
|
||||
export async function saveJWT(token: string) {
|
||||
await StorageService.setItem(JWT_KEY, token)
|
||||
}
|
||||
|
||||
export async function getJWT() {
|
||||
return await StorageService.getItem(JWT_KEY)
|
||||
}
|
||||
|
||||
export async function deleteJWT() {
|
||||
await StorageService.removeItem(JWT_KEY)
|
||||
}
|
||||
105
apps/admin-web/src/hooks/useUploadToObjectStorage.ts
Normal file
105
apps/admin-web/src/hooks/useUploadToObjectStorage.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState } from 'react'
|
||||
import { trpc } from '../lib/trpc-client'
|
||||
import type { ContextString, UploadInput, UploadBatchInput, UploadResult } from '@packages/shared'
|
||||
|
||||
// Verbatim port of admin-ui useUploadToObjectStore (trpc import path only).
|
||||
export function useUploadToObjectStorage() {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [progress, setProgress] = useState<{ completed: number; total: number } | null>(null)
|
||||
|
||||
const generateUploadUrls = trpc.common.generateUploadUrls.useMutation()
|
||||
|
||||
const upload = async (input: UploadBatchInput): Promise<UploadResult> => {
|
||||
setIsUploading(true)
|
||||
setError(null)
|
||||
setProgress({ completed: 0, total: input.images.length })
|
||||
|
||||
try {
|
||||
const { images, contextString } = input
|
||||
|
||||
if (images.length === 0) {
|
||||
return { keys: [], presignedUrls: [] }
|
||||
}
|
||||
|
||||
const mimeTypes = images.map((img) => img.mimeType)
|
||||
const { uploadUrls } = await generateUploadUrls.mutateAsync({
|
||||
contextString,
|
||||
mimeTypes,
|
||||
})
|
||||
|
||||
if (uploadUrls.length !== images.length) {
|
||||
throw new Error(`Expected ${images.length} URLs, got ${uploadUrls.length}`)
|
||||
}
|
||||
|
||||
const uploadPromises = images.map(async (image, index) => {
|
||||
const presignedUrl = uploadUrls[index]
|
||||
const { blob, mimeType } = image
|
||||
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: blob,
|
||||
headers: { 'Content-Type': mimeType },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload ${index + 1} failed with status ${response.status}`)
|
||||
}
|
||||
|
||||
setProgress((prev) => (prev ? { ...prev, completed: prev.completed + 1 } : null))
|
||||
|
||||
return {
|
||||
key: extractKeyFromPresignedUrl(presignedUrl),
|
||||
presignedUrl,
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(uploadPromises)
|
||||
|
||||
return {
|
||||
keys: results.map((r) => r.key),
|
||||
presignedUrls: results.map((r) => r.presignedUrl),
|
||||
}
|
||||
} catch (err) {
|
||||
const uploadError = err instanceof Error ? err : new Error('Upload failed')
|
||||
setError(uploadError)
|
||||
throw uploadError
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadSingle = async (
|
||||
blob: Blob,
|
||||
mimeType: string,
|
||||
contextString: ContextString,
|
||||
): Promise<{ key: string; presignedUrl: string }> => {
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType }],
|
||||
contextString,
|
||||
})
|
||||
return {
|
||||
key: result.keys[0],
|
||||
presignedUrl: result.presignedUrls[0],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploadSingle,
|
||||
isUploading,
|
||||
error,
|
||||
progress,
|
||||
isPending: generateUploadUrls.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
function extractKeyFromPresignedUrl(url: string): string {
|
||||
const u = new URL(url)
|
||||
let rawKey = u.pathname.replace(/^\/+/, '')
|
||||
rawKey = rawKey.split('/').slice(1).join('/')
|
||||
return decodeURIComponent(rawKey)
|
||||
}
|
||||
|
||||
export type { UploadInput }
|
||||
33
apps/admin-web/src/lib/event-bus.ts
Normal file
33
apps/admin-web/src/lib/event-bus.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Web replacement for react-native's DeviceEventEmitter + shared event names.
|
||||
// API-compatible subset: emit(event), addListener(event, cb) -> { remove() }.
|
||||
export const REFRESH_EVENT = 'refresh_event'
|
||||
export const FORCE_LOGOUT_EVENT = 'forceLogout'
|
||||
|
||||
type Listener = (...args: any[]) => void
|
||||
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
|
||||
export const EventBus = {
|
||||
emit(event: string, ...args: any[]) {
|
||||
listeners.get(event)?.forEach((cb) => {
|
||||
try {
|
||||
cb(...args)
|
||||
} catch (e) {
|
||||
console.error(`EventBus listener error for ${event}`, e)
|
||||
}
|
||||
})
|
||||
},
|
||||
addListener(event: string, cb: Listener) {
|
||||
let set = listeners.get(event)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
listeners.set(event, set)
|
||||
}
|
||||
set.add(cb)
|
||||
return {
|
||||
remove() {
|
||||
listeners.get(event)?.delete(cb)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
26
apps/admin-web/src/lib/navigation-target.ts
Normal file
26
apps/admin-web/src/lib/navigation-target.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
// Verbatim port of common-ui useNavigationTarget (only dep: zustand).
|
||||
interface NavigationTargetState {
|
||||
navigationTarget: string | null
|
||||
setNavigationTarget: (target: string | null) => void
|
||||
getNavigationTarget: () => string | null
|
||||
}
|
||||
|
||||
const useNavigationTargetStore = create<NavigationTargetState>((set, get) => ({
|
||||
navigationTarget: null,
|
||||
setNavigationTarget: (target: string | null) => {
|
||||
set({ navigationTarget: target })
|
||||
},
|
||||
getNavigationTarget: () => {
|
||||
const target = get().navigationTarget
|
||||
if (target) {
|
||||
set({ navigationTarget: null })
|
||||
}
|
||||
return target
|
||||
},
|
||||
}))
|
||||
|
||||
export function useNavigationTarget() {
|
||||
return useNavigationTargetStore()
|
||||
}
|
||||
19
apps/admin-web/src/lib/query-client.ts
Normal file
19
apps/admin-web/src/lib/query-client.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
// Module-level singleton — same options as admin-ui (retry: 3).
|
||||
let queryClient: QueryClient | undefined
|
||||
|
||||
export function getQueryClient() {
|
||||
if (!queryClient) {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return queryClient
|
||||
}
|
||||
|
||||
export default getQueryClient()
|
||||
62
apps/admin-web/src/lib/refresh-context.tsx
Normal file
62
apps/admin-web/src/lib/refresh-context.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { createContext, useContext, useEffect, useRef } from 'react'
|
||||
import { useLocation } from '@tanstack/react-router'
|
||||
import { EventBus, REFRESH_EVENT } from './event-bus'
|
||||
import { getQueryClient } from './query-client'
|
||||
|
||||
// Web port of packages/ui refresh-context + useManualRefresh +
|
||||
// useMarkDataFetchers + useFocusCallback. Navigation focus (expo) is
|
||||
// approximated with route-pathname changes; manual refresh uses the bus.
|
||||
|
||||
const RefreshContext = createContext<{ refreshAll: () => void } | null>(null)
|
||||
|
||||
export const RefreshProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const refreshAll = () => {
|
||||
getQueryClient().invalidateQueries()
|
||||
EventBus.emit(REFRESH_EVENT)
|
||||
}
|
||||
return <RefreshContext.Provider value={{ refreshAll }}>{children}</RefreshContext.Provider>
|
||||
}
|
||||
|
||||
export const useRefresh = () => {
|
||||
const context = useContext(RefreshContext)
|
||||
if (!context) throw new Error('useRefresh must be used within RefreshProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function useManualRefresh(callback: () => void) {
|
||||
const ref = useRef(callback)
|
||||
ref.current = callback
|
||||
useEffect(() => {
|
||||
const sub = EventBus.addListener(REFRESH_EVENT, () => ref.current())
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
}
|
||||
|
||||
// Refetch on 2nd+ visit to the current route (mirrors useMarkDataFetchers:
|
||||
// skip first focus, refetch on subsequent focuses).
|
||||
export function useMarkDataFetchers(callback: () => void) {
|
||||
const location = useLocation()
|
||||
const ref = useRef(callback)
|
||||
ref.current = callback
|
||||
const visitsRef = useRef<Record<string, number>>({})
|
||||
useEffect(() => {
|
||||
const key = location.pathname + location.searchStr
|
||||
const count = visitsRef.current[key] ?? 0
|
||||
visitsRef.current[key] = count + 1
|
||||
if (count >= 1) {
|
||||
ref.current()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname, location.searchStr])
|
||||
}
|
||||
|
||||
// Run callback when the window regains focus (web analogue of screen focus).
|
||||
export default function useFocusCallback(callback: () => void) {
|
||||
const ref = useRef(callback)
|
||||
ref.current = callback
|
||||
useEffect(() => {
|
||||
const onFocus = () => ref.current()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [])
|
||||
}
|
||||
60
apps/admin-web/src/lib/theme.ts
Normal file
60
apps/admin-web/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Exact copy of theme.colors from packages/ui (theme-colors.ts), which the
|
||||
// web bundle cannot import (RN code). Keeps admin-web visuals identical.
|
||||
export const colors = {
|
||||
brand25: '#F5FAFF',
|
||||
brand50: '#EFF8FF',
|
||||
brand100: '#D1E9FF',
|
||||
brand200: '#B2DDFF',
|
||||
brand300: '#84CAFF',
|
||||
brand400: '#53B1FD',
|
||||
brand500: '#2E90FA',
|
||||
brand600: '#1570EF',
|
||||
brand700: '#175CD3',
|
||||
brand800: '#1849A9',
|
||||
brand900: '#194185',
|
||||
blue1: '#2E90FA',
|
||||
blue2: '#fff0f6',
|
||||
pink1: '#2E90FA',
|
||||
pink2: '#fff0f6',
|
||||
blue3: '#ECF4FF',
|
||||
red1: '#D84343',
|
||||
green1: '#4CAF50',
|
||||
green2: '#C8F4D3',
|
||||
black1: '#000000',
|
||||
black2: '#1A1C1E',
|
||||
white1: '#FFFFFF',
|
||||
gray1: '#fdfdfd',
|
||||
gray2: '#f2f2f2',
|
||||
gray3: '#F5F5F5',
|
||||
gray4: '#6C7278',
|
||||
gray5: '#ced4da',
|
||||
yellow1: '#FFB74D',
|
||||
yellow2: '#FFE3AD',
|
||||
purple25: '#FAFAFF',
|
||||
purple50: '#F4F3FF',
|
||||
purple100: '#EBE9FE',
|
||||
purple200: '#D9D6FE',
|
||||
purple300: '#BDB4FE',
|
||||
purple400: '#9B8AFB',
|
||||
purple500: '#7A5AF8',
|
||||
purple600: '#6938EF',
|
||||
purple700: '#5925DC',
|
||||
purple800: '#4A1FB8',
|
||||
purple900: '#3E1C96',
|
||||
gray25: '#FDFDFD',
|
||||
gray50: '#FAFAFA',
|
||||
gray100: '#F5F5F5',
|
||||
gray200: '#E9EAEB',
|
||||
gray300: '#D5D7DA',
|
||||
gray400: '#A4A7AE',
|
||||
gray500: '#717680',
|
||||
gray600: '#535862',
|
||||
gray700: '#414651',
|
||||
gray800: '#252B37',
|
||||
gray900: '#101828',
|
||||
secondaryPink: '#fff',
|
||||
}
|
||||
|
||||
export const theme = { colors }
|
||||
|
||||
export type ThemeColor = keyof typeof colors
|
||||
39
apps/admin-web/src/lib/trpc-client.ts
Normal file
39
apps/admin-web/src/lib/trpc-client.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { createTRPCReact } from '@trpc/react-query'
|
||||
import { createTRPCClient, httpBatchLink } from '@trpc/client'
|
||||
import type { AppRouter } from '@backend/trpc/router'
|
||||
import { getJWT } from '../hooks/useJWT'
|
||||
import { EventBus, FORCE_LOGOUT_EVENT } from './event-bus'
|
||||
|
||||
// Same backend as admin-ui (BASE_API_URL from common-ui); overridable via env.
|
||||
export const BASE_API_URL =
|
||||
(import.meta.env?.VITE_API_URL as string | undefined) || 'https://devapi.freshyo.in'
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
|
||||
export function getTrpcClient() {
|
||||
return createTRPCClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${BASE_API_URL}/api/trpc`,
|
||||
headers: async () => {
|
||||
const token = await getJWT()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
const response = await fetch(url, options)
|
||||
if (response.status === 401) {
|
||||
const data = await response.clone().json().catch(() => ({}))
|
||||
let code = ''
|
||||
if (data[0]?.error || data?.error) {
|
||||
code = data[0]?.error?.message || data?.error?.message
|
||||
}
|
||||
if (code === 'UNAUTHORIZED') {
|
||||
EventBus.emit(FORCE_LOGOUT_EVENT)
|
||||
}
|
||||
}
|
||||
return response
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
1047
apps/admin-web/src/routeTree.gen.ts
Normal file
1047
apps/admin-web/src/routeTree.gen.ts
Normal file
File diff suppressed because it is too large
Load diff
19
apps/admin-web/src/router.tsx
Normal file
19
apps/admin-web/src/router.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
64
apps/admin-web/src/routes/__root.tsx
Normal file
64
apps/admin-web/src/routes/__root.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { Toaster } from 'sonner'
|
||||
import { trpc, getTrpcClient } from '../lib/trpc-client'
|
||||
import queryClient from '../lib/query-client'
|
||||
import { StaffAuthProvider } from '../components/context/staff-auth-context'
|
||||
import { RefreshProvider } from '../lib/refresh-context'
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
const trpcClient = getTrpcClient()
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{ title: 'Freshyo Admin' },
|
||||
],
|
||||
links: [
|
||||
{ rel: 'stylesheet', href: appCss },
|
||||
{ rel: 'icon', type: 'image/png', href: '/favicon.png' },
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<StaffAuthProvider>
|
||||
<RefreshProvider>
|
||||
<div id="app">{children}</div>
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<ReactQueryDevtools />
|
||||
</RefreshProvider>
|
||||
</StaffAuthProvider>
|
||||
</trpc.Provider>
|
||||
</QueryClientProvider>
|
||||
<TanStackDevtools
|
||||
config={{ position: 'bottom-right' }}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
152
apps/admin-web/src/routes/dashboard.banners.$id.edit.tsx
Normal file
152
apps/admin-web/src/routes/dashboard.banners.$id.edit.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import BannerForm, { type BannerFormData } from '../components/BannerForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/banners/$id/edit')({
|
||||
component: EditBanner,
|
||||
})
|
||||
|
||||
function EditBanner() {
|
||||
const { id } = Route.useParams()
|
||||
const bannerId = id as string
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
// Load real banner data from API
|
||||
const { data: bannerData } = trpc.admin.banner.getBanner.useQuery({
|
||||
id: parseInt(bannerId)
|
||||
})
|
||||
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
const [banner, setBanner] = useState<typeof bannerData>(undefined)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadBanner = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
|
||||
|
||||
if (bannerData) {
|
||||
// Handle data format compatibility (productId -> skuIds migration)
|
||||
const processedBanner = {
|
||||
...bannerData,
|
||||
skuIds: Array.isArray(bannerData.skuIds)
|
||||
? bannerData.skuIds
|
||||
: (bannerData.skuIds ? [bannerData.skuIds] : [])
|
||||
}
|
||||
|
||||
setBanner(processedBanner)
|
||||
} else {
|
||||
ErrorToast('Banner not found')
|
||||
window.history.back()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load banner:', err)
|
||||
ErrorToast('Failed to load banner data')
|
||||
window.history.back()
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBanner()
|
||||
}, [bannerId, bannerData])
|
||||
|
||||
const initialValues: BannerFormData = banner ? {
|
||||
name: banner.name,
|
||||
imageUrl: banner.imageUrl,
|
||||
description: banner.description || '',
|
||||
skuIds: banner.skuIds || [],
|
||||
redirectUrl: banner.redirectUrl || '',
|
||||
// serialNum removed - handled automatically by backend
|
||||
} : {
|
||||
name: '',
|
||||
imageUrl: '',
|
||||
description: '',
|
||||
skuIds: [],
|
||||
redirectUrl: '',
|
||||
// serialNum removed - handled automatically by backend
|
||||
}
|
||||
|
||||
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
|
||||
|
||||
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
||||
if (!banner) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await updateBannerMutation.mutateAsync({
|
||||
id: banner.id,
|
||||
name: values.name,
|
||||
imageUrl: imageUrl || banner.imageUrl,
|
||||
description: values.description || undefined,
|
||||
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
|
||||
redirectUrl: values.redirectUrl || undefined,
|
||||
})
|
||||
|
||||
await refetchBanners()
|
||||
window.alert('Success: Banner updated successfully')
|
||||
window.history.back()
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to update banner. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-gray-600">Loading banner...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!banner) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-red-600">Banner not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<button onClick={handleCancel} className="p-2" aria-label="Go back">
|
||||
<ArrowLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">Edit Banner</MyText>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<BannerForm
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="Update Banner"
|
||||
isSubmitting={isSubmitting}
|
||||
existingImageUrl={banner.imageUrl}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
83
apps/admin-web/src/routes/dashboard.banners.new.tsx
Normal file
83
apps/admin-web/src/routes/dashboard.banners.new.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import BannerForm, { type BannerFormData } from '../components/BannerForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/banners/new')({
|
||||
component: CreateBanner,
|
||||
})
|
||||
|
||||
function CreateBanner() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const initialValues: BannerFormData = {
|
||||
name: '',
|
||||
imageUrl: '',
|
||||
description: '',
|
||||
skuIds: [],
|
||||
redirectUrl: '',
|
||||
// serialNum removed - assigned automatically by backend
|
||||
}
|
||||
|
||||
const createBannerMutation = trpc.admin.banner.createBanner.useMutation()
|
||||
const { refetch: refetchBanners } = trpc.admin.banner.getBanners.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: BannerFormData, imageUrl?: string) => {
|
||||
if (!imageUrl) {
|
||||
ErrorToast('Image is required')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await createBannerMutation.mutateAsync({
|
||||
name: values.name,
|
||||
imageUrl,
|
||||
description: values.description || undefined,
|
||||
skuIds: values.skuIds.length > 0 ? values.skuIds : [],
|
||||
redirectUrl: values.redirectUrl || undefined,
|
||||
})
|
||||
|
||||
await refetchBanners()
|
||||
window.alert('Success: Banner created successfully')
|
||||
window.history.back()
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to create banner. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||
<button onClick={handleCancel} className="p-2" aria-label="Go back">
|
||||
<ArrowLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">Create Banner</MyText>
|
||||
<div className="w-10" />
|
||||
</div>
|
||||
|
||||
<BannerForm
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="Create Banner"
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
432
apps/admin-web/src/routes/dashboard.banners.tsx
Normal file
432
apps/admin-web/src/routes/dashboard.banners.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import { useManualRefresh } from '@/lib/refresh-context'
|
||||
import { Image as ImageIcon, Plus, Pencil, Trash2, CircleCheck, CircleDot, Hourglass, X } from 'lucide-react'
|
||||
import type { Banner as SharedBanner } from '@packages/shared'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/banners')({
|
||||
component: DashboardBanners,
|
||||
})
|
||||
|
||||
// Serialized banner row (API JSON carries string dates) anchored to the shared shape
|
||||
type Banner = Omit<SharedBanner, 'createdAt' | 'lastUpdated'> & {
|
||||
createdAt: string,
|
||||
lastUpdated: string
|
||||
}
|
||||
|
||||
function DashboardBanners() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
// Edit mode state
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
|
||||
const [slotAssignments, setSlotAssignments] = useState<{ [slot: number]: number | null }>({})
|
||||
const [originalAssignments, setOriginalAssignments] = useState<{ [slot: number]: number | null }>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Real API calls
|
||||
const { data: bannersData, isLoading, error, refetch } = trpc.admin.banner.getBanners.useQuery()
|
||||
|
||||
const deleteBannerMutation = trpc.admin.banner.deleteBanner.useMutation()
|
||||
const updateBannerMutation = trpc.admin.banner.updateBanner.useMutation()
|
||||
const emptySlotMutation = trpc.admin.banner.updateBanner.useMutation()
|
||||
|
||||
const banners = bannersData?.banners || []
|
||||
|
||||
// Initialize slot assignments when banners load
|
||||
React.useEffect(() => {
|
||||
const assignments: { [slot: number]: number | null } = { 1: null, 2: null, 3: null, 4: null }
|
||||
banners.forEach((banner) => {
|
||||
if (banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4) {
|
||||
assignments[banner.serialNum] = banner.id
|
||||
}
|
||||
})
|
||||
setSlotAssignments(assignments)
|
||||
setOriginalAssignments({ ...assignments })
|
||||
}, [bannersData])
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
useManualRefresh(() => {
|
||||
onRefresh()
|
||||
})
|
||||
|
||||
if (location.pathname !== '/dashboard/banners') {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// Slot and edit mode handlers
|
||||
const handleSlotClick = (slotNumber: number) => {
|
||||
setSelectedSlot(slotNumber)
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
const handleBannerSelect = (bannerId: number) => {
|
||||
if (!editMode || selectedSlot === null) return
|
||||
|
||||
// Remove banner from any existing slot
|
||||
const newAssignments = { ...slotAssignments }
|
||||
Object.keys(newAssignments).forEach((slot) => {
|
||||
if (newAssignments[parseInt(slot)] === bannerId) {
|
||||
newAssignments[parseInt(slot)] = null
|
||||
}
|
||||
})
|
||||
|
||||
// Assign banner to selected slot
|
||||
newAssignments[selectedSlot] = bannerId
|
||||
setSlotAssignments(newAssignments)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Get banners that need to be updated
|
||||
const bannersToUpdate: { id: number, serialNum: number | null }[] = []
|
||||
|
||||
// Clear serial numbers for banners no longer in slots
|
||||
banners.forEach((banner) => {
|
||||
const currentSlot = banner.serialNum
|
||||
const assignedSlot = Object.keys(slotAssignments).find((slot) =>
|
||||
slotAssignments[parseInt(slot)] === banner.id
|
||||
)
|
||||
|
||||
if (currentSlot !== (assignedSlot ? parseInt(assignedSlot) : null)) {
|
||||
bannersToUpdate.push({
|
||||
id: banner.id,
|
||||
serialNum: assignedSlot ? parseInt(assignedSlot) : null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Update banners that gained slots
|
||||
Object.keys(slotAssignments).forEach((slot) => {
|
||||
const slotNum = parseInt(slot)
|
||||
const bannerId = slotAssignments[slotNum]
|
||||
if (bannerId) {
|
||||
const banner = banners.find((b) => b.id === bannerId)
|
||||
if (banner && banner.serialNum !== slotNum) {
|
||||
bannersToUpdate.push({
|
||||
id: bannerId,
|
||||
serialNum: slotNum
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Execute updates
|
||||
await Promise.all(
|
||||
bannersToUpdate.map(({ id, serialNum }) =>
|
||||
updateBannerMutation.mutateAsync({ id, serialNum })
|
||||
)
|
||||
)
|
||||
|
||||
setOriginalAssignments({ ...slotAssignments })
|
||||
setEditMode(false)
|
||||
setSelectedSlot(null)
|
||||
await refetch()
|
||||
window.alert('Success: Slot assignments saved successfully')
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to save slot assignments')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSlotAssignments({ ...originalAssignments })
|
||||
setEditMode(false)
|
||||
setSelectedSlot(null)
|
||||
}
|
||||
|
||||
const handleEmptySlot = async () => {
|
||||
if (!selectedSlot || !slotAssignments[selectedSlot]) return
|
||||
|
||||
const bannerId = slotAssignments[selectedSlot]
|
||||
const banner = banners.find((b) => b.id === bannerId)
|
||||
|
||||
if (!banner) return
|
||||
|
||||
try {
|
||||
// Update banner's serialNum to null to empty the slot
|
||||
await emptySlotMutation.mutateAsync({
|
||||
id: banner.id,
|
||||
serialNum: null
|
||||
})
|
||||
|
||||
// Update local state
|
||||
setSlotAssignments((prev) => ({
|
||||
...prev,
|
||||
[selectedSlot]: null
|
||||
}))
|
||||
|
||||
// Update original assignments for cancel functionality
|
||||
setOriginalAssignments((prev) => ({
|
||||
...prev,
|
||||
[selectedSlot]: null
|
||||
}))
|
||||
|
||||
window.alert(`Success: Slot ${selectedSlot} has been emptied`)
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to empty slot')
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get banner name by ID
|
||||
const getBannerNameById = (id: number | null) => {
|
||||
if (!id) return null
|
||||
const banner = banners.find((b) => b.id === id)
|
||||
return banner ? banner.name : null
|
||||
}
|
||||
|
||||
const handleEdit = (banner: Banner) => {
|
||||
navigate({ to: '/dashboard/banners/$id/edit', params: { id: String(banner.id) } })
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (!window.confirm('Delete Banner: Are you sure you want to delete this banner?')) return
|
||||
;(async () => {
|
||||
try {
|
||||
await deleteBannerMutation.mutateAsync({ id })
|
||||
refetch()
|
||||
window.alert('Success: Banner deleted')
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to delete banner')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate({ to: '/dashboard/banners/new' })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-gray-600">Loading banners...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-red-600">Error loading banners</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="relative flex-1 bg-gray-50">
|
||||
<div className="max-h-[calc(100vh-160px)] flex-1 overflow-y-auto pb-24">
|
||||
{/* Slots Row */}
|
||||
<div className="mb-6 px-4">
|
||||
<MyText className="mb-3 block text-sm font-medium text-gray-700">
|
||||
{editMode ? `Select banner for Slot ${selectedSlot}` : 'Banner Slots'}
|
||||
</MyText>
|
||||
<div className="flex flex-row justify-between">
|
||||
{[1, 2, 3, 4].map((slotNum) => {
|
||||
const assignedBannerId = slotAssignments[slotNum]
|
||||
const bannerName = getBannerNameById(assignedBannerId)
|
||||
const isSelected = editMode && selectedSlot === slotNum
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slotNum}
|
||||
onClick={() => handleSlotClick(slotNum)}
|
||||
className={`mx-1 flex-1 rounded-lg border-2 p-3 ${isSelected
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: assignedBannerId
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="items-center">
|
||||
<MyText className="mb-1 block text-xs text-gray-500">Slot {slotNum}</MyText>
|
||||
<MyText
|
||||
className={`block text-center text-xs font-medium ${assignedBannerId ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{bannerName || 'Empty'}
|
||||
</MyText>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons in edit mode */}
|
||||
{editMode && (
|
||||
<div className="mt-4 gap-3">
|
||||
{/* Save/Cancel buttons */}
|
||||
<div className="flex flex-row gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
className={`flex-1 items-center rounded-lg bg-gray-500 px-4 py-3 ${saving ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<MyText className="font-semibold text-white">Cancel</MyText>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`flex-1 items-center rounded-lg bg-blue-600 px-4 py-3 ${saving ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<MyText className="font-semibold text-white">
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Empty Slot button */}
|
||||
<button
|
||||
onClick={handleEmptySlot}
|
||||
disabled={!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending}
|
||||
className={`mt-3 w-full items-center rounded-lg bg-red-500 px-4 py-3 ${(!slotAssignments[selectedSlot || 0] || emptySlotMutation.isPending) ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex flex-row items-center justify-center">
|
||||
{emptySlotMutation.isPending ? (
|
||||
<Hourglass size={16} color="white" />
|
||||
) : (
|
||||
<X size={16} color="white" />
|
||||
)}
|
||||
<MyText className="ml-2 font-semibold text-white">
|
||||
{emptySlotMutation.isPending ? 'Emptying...' : 'Empty Slot'}
|
||||
</MyText>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Banners List */}
|
||||
<div className="px-4 pb-8">
|
||||
{editMode && (
|
||||
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<MyText className="block text-center text-sm text-blue-800">
|
||||
Tap a banner below to assign it to Slot {selectedSlot}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{banners.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-8">
|
||||
<span className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
|
||||
<ImageIcon size={48} color="#94A3B8" />
|
||||
</span>
|
||||
<MyText className="block text-xl font-black tracking-tight text-slate-900">No Banners Yet</MyText>
|
||||
<MyText className="mt-2 block px-8 text-center font-medium text-slate-500">
|
||||
Start by creating your first banner using the button above.
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
banners.map((banner) => {
|
||||
const isAssignedToSlot = Object.values(slotAssignments).includes(banner.id)
|
||||
const isAssignedToSelectedSlot = slotAssignments[selectedSlot || 0] === banner.id
|
||||
const canInteract = editMode
|
||||
|
||||
return (
|
||||
<div
|
||||
key={banner.id}
|
||||
onClick={canInteract ? () => handleBannerSelect(banner.id) : undefined}
|
||||
role={canInteract ? 'button' : undefined}
|
||||
tabIndex={canInteract ? 0 : undefined}
|
||||
onKeyDown={canInteract ? (e) => { if (e.key === 'Enter') handleBannerSelect(banner.id) } : undefined}
|
||||
className={`mb-3 w-full rounded-xl border bg-white p-4 text-left shadow-sm ${canInteract ? 'cursor-pointer' : ''} ${canInteract && isAssignedToSelectedSlot
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: canInteract && isAssignedToSlot
|
||||
? 'border-green-300 bg-green-50'
|
||||
: canInteract
|
||||
? 'border-gray-200'
|
||||
: 'border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="flex flex-row items-center">
|
||||
<img
|
||||
src={banner.imageUrl}
|
||||
className="mr-3 h-16 w-16 rounded-lg object-cover"
|
||||
alt={banner.name}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="mb-1 flex flex-row items-center justify-between">
|
||||
<MyText className="font-semibold text-gray-900" numberOfLines={1}>
|
||||
{banner.name}
|
||||
</MyText>
|
||||
<span className="flex flex-row items-center">
|
||||
{canInteract && isAssignedToSelectedSlot && (
|
||||
<span className="mr-2">
|
||||
<CircleCheck size={16} color="#3B82F6" />
|
||||
</span>
|
||||
)}
|
||||
{canInteract && isAssignedToSlot && !isAssignedToSelectedSlot && (
|
||||
<span className="mr-2">
|
||||
<CircleDot size={16} color="#10B981" />
|
||||
</span>
|
||||
)}
|
||||
<span className={`mr-2 h-2 w-2 rounded-full ${banner.isActive ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
{!editMode && (
|
||||
<>
|
||||
<button onClick={(e) => { e.stopPropagation(), handleEdit(banner as Banner) }} className="mr-1 p-1" aria-label="Edit banner">
|
||||
<Pencil size={16} color="#64748B" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(), handleDelete(banner.id) }} className="p-1" aria-label="Delete banner">
|
||||
<Trash2 size={16} color="#EF4444" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{banner.description && (
|
||||
<MyText className="mt-1 block text-sm text-gray-500" numberOfLines={2}>
|
||||
{banner.description}
|
||||
</MyText>
|
||||
)}
|
||||
<span className="mt-2 flex flex-row items-center justify-between">
|
||||
<MyText className="text-xs text-gray-400">
|
||||
Created: {new Date(banner.createdAt).toLocaleDateString()}
|
||||
</MyText>
|
||||
{banner.serialNum && banner.serialNum >= 1 && banner.serialNum <= 4 && (
|
||||
<MyText className="text-xs font-medium text-blue-600">
|
||||
Slot {banner.serialNum}
|
||||
</MyText>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fixed bottom-6 right-6 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 shadow-lg"
|
||||
aria-label="Add new banner"
|
||||
>
|
||||
<Plus size={24} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
314
apps/admin-web/src/routes/dashboard.complaints.tsx
Normal file
314
apps/admin-web/src/routes/dashboard.complaints.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { CircleAlert, Reply, User as UserIcon, ShoppingBag, Inbox } from 'lucide-react'
|
||||
import { p as MyText, MyFlatList, MyButton, ConfirmationDialog } from 'web-components'
|
||||
import { useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/complaints')({
|
||||
component: Complaints,
|
||||
})
|
||||
|
||||
function Complaints() {
|
||||
const navigate = useNavigate()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [selectedComplaintId, setSelectedComplaintId] = useState<number | null>(null)
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [viewerImages, setViewerImages] = useState<string[]>([])
|
||||
const [viewerIndex, setViewerIndex] = useState(0)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = trpc.admin.complaint.getAll.useInfiniteQuery(
|
||||
{ limit: 20 },
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const resolveComplaint = trpc.admin.complaint.resolve.useMutation()
|
||||
|
||||
const complaints = useMemo(() => {
|
||||
const allComplaints = data?.pages.flatMap((page) => page.complaints) || []
|
||||
return allComplaints.filter(
|
||||
(complaint, index, self) =>
|
||||
index === self.findIndex((c) => c.id === complaint.id)
|
||||
)
|
||||
}, [data])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
const handleUserPress = useCallback((userId: number) => {
|
||||
navigate({ to: '/dashboard/users/$id', params: { id: String(userId) } })
|
||||
}, [navigate])
|
||||
|
||||
const handleOrderPress = useCallback((orderId: number) => {
|
||||
navigate({ to: '/dashboard/orders/$id', params: { id: String(orderId) } })
|
||||
}, [navigate])
|
||||
|
||||
const handleMarkResolved = (id: number) => {
|
||||
setSelectedComplaintId(id)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmResolve = (response?: string) => {
|
||||
if (!selectedComplaintId) return
|
||||
|
||||
resolveComplaint.mutate(
|
||||
{ id: String(selectedComplaintId), response: response ?? '' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Complaint marked as resolved')
|
||||
refetch()
|
||||
setDialogOpen(false)
|
||||
setSelectedComplaintId(null)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Error: ${error.message || 'Failed to resolve complaint'}`)
|
||||
setDialogOpen(false)
|
||||
setSelectedComplaintId(null)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const openImageViewer = (images: string[], index: number) => {
|
||||
setViewerImages(images)
|
||||
setViewerIndex(index)
|
||||
setViewerOpen(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading complaints...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-gray-50 p-6">
|
||||
<CircleAlert size={48} color="#EF4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mb-6 mt-2 text-center text-gray-500">
|
||||
{error?.message || 'Failed to load complaints'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="px-4 py-4">
|
||||
<MyFlatList
|
||||
data={complaints}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => (
|
||||
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex-row flex items-start justify-between">
|
||||
<MyText className="text-lg font-bold text-gray-900">
|
||||
Complaint #{item.id}
|
||||
</MyText>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-1 ${
|
||||
item.status === 'resolved'
|
||||
? 'border border-green-200 bg-green-100'
|
||||
: 'border border-amber-200 bg-amber-100'
|
||||
}`}
|
||||
>
|
||||
<MyText
|
||||
className={`text-xs font-semibold ${
|
||||
item.status === 'resolved' ? 'text-green-700' : 'text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'resolved' ? 'Resolved' : 'Pending'}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<MyText className="mb-3 text-base leading-5 text-gray-700">
|
||||
{item.text}
|
||||
</MyText>
|
||||
|
||||
{item.response && (
|
||||
<div className="mb-3 rounded-xl border border-blue-100 bg-blue-50 p-3">
|
||||
<div className="mb-1 flex-row flex items-center">
|
||||
<Reply size={16} color="#2563EB" />
|
||||
<MyText className="ml-1 text-xs font-bold text-blue-700">
|
||||
Admin Reply
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm leading-5 text-gray-700">
|
||||
{item.response}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.images && item.images.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<MyText className="mb-2 text-sm font-semibold text-gray-700">
|
||||
Attached Images:
|
||||
</MyText>
|
||||
<div className="flex-row flex flex-wrap gap-2">
|
||||
{item.images.map((imageUri: string, index: number) => (
|
||||
<img
|
||||
key={index}
|
||||
src={imageUri}
|
||||
alt={`Complaint attachment ${index + 1}`}
|
||||
onClick={() => openImageViewer(item.images as string[], index)}
|
||||
className="h-16 w-16 cursor-pointer rounded-lg border border-gray-200 object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex-row flex items-center gap-2">
|
||||
<UserIcon size={14} color="#6B7280" />
|
||||
<button
|
||||
onClick={() => item.userId && handleUserPress(item.userId)}
|
||||
>
|
||||
<MyText className="text-sm text-blue-600 underline">
|
||||
{item.userName || item.userMobile || 'Unknown User'}
|
||||
</MyText>
|
||||
</button>
|
||||
{item.orderId && (
|
||||
<>
|
||||
<MyText className="text-sm text-gray-400">|</MyText>
|
||||
<ShoppingBag size={14} color="#6B7280" />
|
||||
<button
|
||||
onClick={() => item.orderId && handleOrderPress(item.orderId)}
|
||||
>
|
||||
<MyText className="text-sm text-blue-600 underline">
|
||||
Order #{item.orderId}
|
||||
</MyText>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleMarkResolved(item.id)}
|
||||
className="mt-2 items-center rounded-xl bg-blue-500 py-3 shadow-sm"
|
||||
>
|
||||
<MyText className="font-semibold text-white">
|
||||
Resolve Complaint
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<div className="mb-4 rounded-full bg-white p-6 shadow-sm">
|
||||
<Inbox size={48} color="#D1D5DB" />
|
||||
</div>
|
||||
<MyText className="text-lg font-bold text-gray-900">
|
||||
No complaints
|
||||
</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
All complaints will appear here
|
||||
</MyText>
|
||||
</div>
|
||||
}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<div className="flex-row flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="ml-2 text-gray-500">Loading more...</MyText>
|
||||
</div>
|
||||
) : hasNextPage ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<MyButton textContent="Load more" onClick={handleLoadMore} variant="blue" />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ConfirmationDialog
|
||||
open={dialogOpen}
|
||||
positiveAction={handleConfirmResolve}
|
||||
commentNeeded={true}
|
||||
negativeAction={() => {
|
||||
setDialogOpen(false)
|
||||
setSelectedComplaintId(null)
|
||||
}}
|
||||
title="Resolve Complaint"
|
||||
message="Add admin notes for this resolution:"
|
||||
confirmText="Resolve"
|
||||
cancelText="Cancel"
|
||||
isLoading={resolveComplaint.isPending}
|
||||
/>
|
||||
{viewerOpen && viewerImages.length > 0 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90" onClick={() => setViewerOpen(false)}>
|
||||
<button
|
||||
onClick={() => setViewerOpen(false)}
|
||||
className="absolute right-4 top-4 text-white hover:text-gray-300"
|
||||
aria-label="Close viewer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{viewerImages.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setViewerIndex((i) => Math.max(0, i - 1))
|
||||
}}
|
||||
disabled={viewerIndex === 0}
|
||||
className="absolute left-4 text-white disabled:opacity-30"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={viewerImages[viewerIndex]}
|
||||
alt="Complaint attachment viewer"
|
||||
className="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{viewerImages.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setViewerIndex((i) => Math.min(viewerImages.length - 1, i + 1))
|
||||
}}
|
||||
disabled={viewerIndex === viewerImages.length - 1}
|
||||
className="absolute right-4 text-white disabled:opacity-30"
|
||||
aria-label="Next image"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute bottom-4 text-sm text-white">
|
||||
{viewerIndex + 1} / {viewerImages.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
apps/admin-web/src/routes/dashboard.coupons.$id.edit.tsx
Normal file
126
apps/admin-web/src/routes/dashboard.coupons.$id.edit.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import CouponForm from '../components/CouponForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// Ported from common-ui/shared-types (CreateCouponPayload) — @packages/shared
|
||||
// does not export it, so the shape is copied here to keep behavior identical.
|
||||
interface CreateCouponPayload {
|
||||
couponCodes: string[],
|
||||
discountPercent?: number,
|
||||
flatDiscount?: number,
|
||||
minOrder?: number,
|
||||
maxValue?: number,
|
||||
validTill?: string,
|
||||
maxLimitForUser?: number,
|
||||
isApplyForAll: boolean,
|
||||
isUserBased: boolean,
|
||||
targetUsers?: number[],
|
||||
productIds?: number[],
|
||||
skuIds?: number[],
|
||||
applicableUsers?: number[],
|
||||
applicableProducts?: number[],
|
||||
exclusiveApply?: boolean
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/dashboard/coupons/$id/edit')({
|
||||
component: EditCoupon,
|
||||
})
|
||||
|
||||
function EditCoupon() {
|
||||
const { id } = Route.useParams()
|
||||
const couponId = parseInt(id as string)
|
||||
|
||||
const { data: coupon, isLoading, refetch } = trpc.admin.coupon.getById.useQuery({ id: couponId })
|
||||
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||
{ limit: 20, search: '' },
|
||||
{
|
||||
enabled: false,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||
{ limit: 20, search: '' },
|
||||
{
|
||||
enabled: false,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
const updateCoupon = trpc.admin.coupon.update.useMutation()
|
||||
|
||||
const handleUpdateCoupon = (values: CreateCouponPayload & { isReservedCoupon?: boolean }) => {
|
||||
// Transform targetUsers array to targetUser for backend compatibility
|
||||
const { targetUsers, ...rest } = values
|
||||
const updates = {
|
||||
...rest,
|
||||
targetUser: targetUsers?.[0] || undefined,
|
||||
}
|
||||
|
||||
// For flat discounts, send the discount value as maxValue too
|
||||
if (updates.flatDiscount !== undefined) {
|
||||
updates.maxValue = updates.flatDiscount
|
||||
}
|
||||
|
||||
updateCoupon.mutate({ id: couponId, updates }, {
|
||||
onSuccess: async () => {
|
||||
await refetch()
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
window.alert('Success: Coupon updated successfully')
|
||||
window.history.back()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to update coupon')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Loading...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!coupon) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Coupon not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
// Transform coupon data for form (targetUser to targetUsers array)
|
||||
const initialValues: Partial<CreateCouponPayload & { isReservedCoupon?: boolean }> = {
|
||||
couponCodes: coupon.couponCode ? [coupon.couponCode] : [''],
|
||||
isUserBased: coupon.isUserBased,
|
||||
isApplyForAll: coupon.isApplyForAll,
|
||||
targetUsers: coupon.targetUser ? [coupon.targetUser.id] : [],
|
||||
discountPercent: coupon.discountPercent ? parseFloat(coupon.discountPercent) : undefined,
|
||||
flatDiscount: coupon.flatDiscount ? parseFloat(coupon.flatDiscount) : undefined,
|
||||
minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined,
|
||||
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
|
||||
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
|
||||
maxLimitForUser: coupon.maxLimitForUser || undefined,
|
||||
skuIds: coupon.skuIds,
|
||||
isReservedCoupon: false, // Normal coupons
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<CouponForm
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleUpdateCoupon}
|
||||
isLoading={updateCoupon.isPending}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
70
apps/admin-web/src/routes/dashboard.coupons.new.tsx
Normal file
70
apps/admin-web/src/routes/dashboard.coupons.new.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer } from 'web-components'
|
||||
import CouponForm from '../components/CouponForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/coupons/new')({
|
||||
component: CreateCoupon,
|
||||
})
|
||||
|
||||
function CreateCoupon() {
|
||||
const createCoupon = trpc.admin.coupon.create.useMutation()
|
||||
const createReservedCoupon = trpc.admin.coupon.createReservedCoupon.useMutation()
|
||||
const { refetch: refetchCoupons } = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||
{ limit: 20, search: '' },
|
||||
{
|
||||
enabled: false,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
const { refetch: refetchReservedCoupons } = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||
{ limit: 20, search: '' },
|
||||
{
|
||||
enabled: false,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreateCoupon = async (values: any) => {
|
||||
const { isReservedCoupon, couponCodes, ...rest } = values
|
||||
// Transform targetUsers array to targetUser for backend compatibility
|
||||
const payload = {
|
||||
...rest,
|
||||
targetUser: rest.targetUsers?.[0] || undefined,
|
||||
}
|
||||
delete payload.targetUsers
|
||||
|
||||
// For flat discounts, send the discount value as maxValue too
|
||||
if (payload.flatDiscount !== undefined) {
|
||||
payload.maxValue = payload.flatDiscount
|
||||
}
|
||||
|
||||
const codes = (couponCodes || []).map((c: string) => c.trim()).filter(Boolean)
|
||||
if (codes.length === 0) {
|
||||
ErrorToast('At least one coupon code is required')
|
||||
return
|
||||
}
|
||||
|
||||
const mutation = isReservedCoupon ? createReservedCoupon : createCoupon
|
||||
try {
|
||||
await mutation.mutateAsync({ ...payload, couponCodes: codes })
|
||||
await refetchCoupons()
|
||||
await refetchReservedCoupons()
|
||||
window.alert(`Success: ${codes.length} coupon${codes.length > 1 ? 's' : ''} created successfully`)
|
||||
window.history.back()
|
||||
} catch (error: any) {
|
||||
ErrorToast(error.message || 'Failed to create coupons')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<CouponForm
|
||||
onSubmit={handleCreateCoupon}
|
||||
isLoading={createCoupon.isPending || createReservedCoupon.isPending}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
509
apps/admin-web/src/routes/dashboard.coupons.tsx
Normal file
509
apps/admin-web/src/routes/dashboard.coupons.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { Percent, IndianRupee, Pencil, Trash2, TicketPercent, CircleAlert, Loader2, Users, Package, UserCheck, User, ListFilter, Plus } from 'lucide-react'
|
||||
import { p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import { MultiSelect } from '@/components/MultiSelect'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/coupons')({
|
||||
component: Coupons,
|
||||
})
|
||||
|
||||
const CouponItem = ({ item, onDelete }: { item: any, onDelete: (id: number) => void }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const getCouponStatus = (coupon: any) => {
|
||||
if (coupon.isInvalidated) return 'inactive'
|
||||
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const status = getCouponStatus(item)
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (status === 'active') return 'border-green-500'
|
||||
if (status === 'expired') return 'border-yellow-500'
|
||||
return 'border-red-500'
|
||||
}
|
||||
|
||||
const getBgColor = () => {
|
||||
if (status === 'active') return 'bg-green-100'
|
||||
if (status === 'expired') return 'bg-yellow-100'
|
||||
return 'bg-red-100'
|
||||
}
|
||||
|
||||
const getTextColor = () => {
|
||||
if (status === 'active') return 'text-green-600'
|
||||
if (status === 'expired') return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
const getIconColor = () => {
|
||||
if (status === 'active') return '#10b981'
|
||||
if (status === 'expired') return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mb-4 rounded-2xl border-l-4 bg-white p-4 shadow-lg ${getBorderColor()}`}>
|
||||
<div className="mb-3 flex flex-row items-center">
|
||||
<span className={`mr-3 flex h-10 w-10 items-center justify-center rounded-full ${getBgColor()}`}>
|
||||
{item.discountPercent ? (
|
||||
<Percent size={20} color={getIconColor()} />
|
||||
) : (
|
||||
<IndianRupee size={20} color={getIconColor()} />
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<MyText className="block text-lg font-bold text-gray-800">{item.couponCode}</MyText>
|
||||
<MyText className="block text-sm text-gray-500">ID: {item.id}</MyText>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-1 ${getBgColor()}`}>
|
||||
<MyText className={`text-xs font-semibold ${getTextColor()}`}>
|
||||
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Inactive'}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg bg-gray-50 p-3">
|
||||
<MyText className="mb-1 block text-base font-semibold text-gray-800">
|
||||
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||
</MyText>
|
||||
<div className="flex flex-row justify-between">
|
||||
<MyText className="text-sm text-gray-600">Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||
<MyText className="text-sm text-gray-600">Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||
</div>
|
||||
<MyText className="mt-1 block text-sm text-gray-600">
|
||||
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<MyText className="mb-1 flex items-center gap-1 text-sm text-gray-700">
|
||||
<Users size={14} color="#6b7280" /> Target: {item.isApplyForAll ? 'All Users' : item.applicableUsers?.length > 0 ? `${item.applicableUsers.length} Users` : 'All Users'}
|
||||
</MyText>
|
||||
{item.applicableProducts && item.applicableProducts.length > 0 && (
|
||||
<MyText className="flex items-center gap-1 text-sm text-gray-700">
|
||||
<Package size={14} color="#6b7280" /> Products: {item.applicableProducts.length} selected
|
||||
</MyText>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-row gap-2">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/coupons/$id/edit', params: { id: String(item.id) } })}
|
||||
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-blue-500 p-3 shadow-md"
|
||||
>
|
||||
<Pencil size={16} color="white" />
|
||||
<MyText className="ml-1 text-center font-semibold text-white">Edit</MyText>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-red-500 p-3 shadow-md"
|
||||
>
|
||||
<Trash2 size={16} color="white" />
|
||||
<MyText className="ml-1 text-center font-semibold text-white">Delete</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReservedCouponItem = ({ item }: { item: any }) => {
|
||||
const getStatus = () => {
|
||||
if (item.isRedeemed) return 'redeemed'
|
||||
if (item.validTill && new Date(item.validTill) <= new Date()) return 'expired'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const status = getStatus()
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (status === 'active') return 'border-green-500'
|
||||
if (status === 'expired') return 'border-yellow-500'
|
||||
return 'border-blue-500'
|
||||
}
|
||||
|
||||
const getBgColor = () => {
|
||||
if (status === 'active') return 'bg-green-100'
|
||||
if (status === 'expired') return 'bg-yellow-100'
|
||||
return 'bg-blue-100'
|
||||
}
|
||||
|
||||
const getTextColor = () => {
|
||||
if (status === 'active') return 'text-green-600'
|
||||
if (status === 'expired') return 'text-yellow-600'
|
||||
return 'text-blue-600'
|
||||
}
|
||||
|
||||
const getIconColor = () => {
|
||||
if (status === 'active') return '#10b981'
|
||||
if (status === 'expired') return '#f59e0b'
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mb-4 rounded-2xl border-l-4 bg-white p-4 shadow-lg ${getBorderColor()}`}>
|
||||
<div className="mb-3 flex flex-row items-center">
|
||||
<span className={`mr-3 flex h-10 w-10 items-center justify-center rounded-full ${getBgColor()}`}>
|
||||
{item.discountPercent ? (
|
||||
<Percent size={20} color={getIconColor()} />
|
||||
) : (
|
||||
<IndianRupee size={20} color={getIconColor()} />
|
||||
)}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<MyText className="block text-lg font-bold text-gray-800">{item.secretCode}</MyText>
|
||||
<MyText className="block text-sm text-gray-500">Coupon: {item.couponCode}</MyText>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-1 ${getBgColor()}`}>
|
||||
<MyText className={`text-xs font-semibold ${getTextColor()}`}>
|
||||
{status === 'active' ? 'Active' : status === 'expired' ? 'Expired' : 'Redeemed'}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded-lg bg-gray-50 p-3">
|
||||
<MyText className="mb-1 block text-base font-semibold text-gray-800">
|
||||
Discount: {item.discountPercent ? `${item.discountPercent}% off` : item.flatDiscount ? `₹${item.flatDiscount} off` : 'N/A'}
|
||||
</MyText>
|
||||
<div className="flex flex-row justify-between">
|
||||
<MyText className="text-sm text-gray-600">Min Order: {item.minOrder ? `₹${item.minOrder}` : 'None'}</MyText>
|
||||
<MyText className="text-sm text-gray-600">Max: {item.maxValue ? `₹${item.maxValue}` : 'None'}</MyText>
|
||||
</div>
|
||||
<MyText className="mt-1 block text-sm text-gray-600">
|
||||
Valid Till: {item.validTill ? new Date(item.validTill).toLocaleDateString() : 'No expiry'}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{item.isRedeemed && item.redeemedUser && (
|
||||
<div className="mb-3">
|
||||
<MyText className="flex items-center gap-1 text-sm text-gray-700">
|
||||
<UserCheck size={14} color="#6b7280" /> Redeemed by: {item.redeemedUser.name || 'Unknown'} ({item.redeemedUser.mobile})
|
||||
</MyText>
|
||||
<MyText className="block text-sm text-gray-600">
|
||||
Redeemed on: {item.redeemedAt ? new Date(item.redeemedAt).toLocaleDateString() : 'N/A'}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-row items-center justify-between">
|
||||
<MyText className="flex items-center gap-1 text-sm text-gray-700">
|
||||
<User size={14} color="#6b7280" /> Created by: {item.creator?.name || 'Unknown'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GeneralTab = () => {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilters, setStatusFilters] = useState<string[]>([])
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.coupon.getAll.useInfiniteQuery(
|
||||
{ limit: 20, search: searchQuery },
|
||||
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||
)
|
||||
|
||||
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data])
|
||||
|
||||
const getCouponStatus = (coupon: any) => {
|
||||
if (coupon.isInvalidated) return 'inactive'
|
||||
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const filteredCoupons = useMemo(() => {
|
||||
let filtered = coupons
|
||||
if (statusFilters.length > 0) {
|
||||
filtered = filtered.filter((coupon) => statusFilters.includes(getCouponStatus(coupon)))
|
||||
}
|
||||
return filtered
|
||||
}, [coupons, statusFilters])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}, [refetch])
|
||||
|
||||
useManualRefresh(() => { refetch() })
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const deleteCoupon = trpc.admin.coupon.delete.useMutation()
|
||||
|
||||
const handleDeleteCoupon = (id: number) => {
|
||||
if (!window.confirm('Delete Coupon: Are you sure you want to delete this coupon?')) return
|
||||
deleteCoupon.mutate({ id }, {
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Coupon deleted successfully')
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to delete coupon')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<Loader2 size={32} color="#3b82f6" className="mb-4 animate-spin" />
|
||||
<MyText className="text-lg font-semibold text-gray-600">Loading Coupons...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white p-4">
|
||||
<CircleAlert size={32} color="#ef4444" className="mb-4" />
|
||||
<MyText className="mb-2 block text-center text-lg font-semibold text-red-600">Failed to load coupons</MyText>
|
||||
<MyButton onClick={() => refetch()} variant="red">
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</MyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row items-center px-4 pt-2">
|
||||
<div className="mr-2 flex-1">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search coupons..."
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
label="Filter by Status"
|
||||
value={statusFilters}
|
||||
options={[
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
]}
|
||||
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||
multiple={true}
|
||||
triggerComponent={({ onClick }: any) => (
|
||||
<button onClick={onClick} className="p-2" aria-label="Filter by status">
|
||||
<ListFilter size={24} color="#6b7280" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<MyFlatList
|
||||
data={filteredCoupons}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => <CouponItem item={item} onDelete={handleDeleteCoupon} />}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className="px-4 pb-4"
|
||||
ListFooterComponent={
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{isFetchingNextPage ? (
|
||||
<MyText className="text-gray-600">Loading more...</MyText>
|
||||
) : hasNextPage ? (
|
||||
<MyButton variant="blue" onClick={() => fetchNextPage()}>
|
||||
Load more
|
||||
</MyButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-20">
|
||||
<TicketPercent size={40} color="#9ca3af" className="mb-4" />
|
||||
<MyText className="mb-2 block text-xl font-semibold text-gray-600">No Coupons Found</MyText>
|
||||
{searchQuery ? (
|
||||
<MyButton onClick={() => setSearchQuery('')} variant="blue" className="mt-4 bg-gray-500">
|
||||
<MyText className="font-semibold text-white">Clear Search</MyText>
|
||||
</MyButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReservedTab = () => {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilters, setStatusFilters] = useState<string[]>([])
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
error,
|
||||
refetch
|
||||
} = trpc.admin.coupon.getReservedCoupons.useInfiniteQuery(
|
||||
{ limit: 20, search: searchQuery },
|
||||
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
|
||||
)
|
||||
|
||||
const coupons = useMemo(() => data?.pages.flatMap((page) => page.coupons) || [], [data])
|
||||
|
||||
const getStatus = (coupon: any) => {
|
||||
if (coupon.isRedeemed) return 'redeemed'
|
||||
if (coupon.validTill && new Date(coupon.validTill) <= new Date()) return 'expired'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const filteredCoupons = useMemo(() => {
|
||||
let filtered = coupons
|
||||
if (statusFilters.length > 0) {
|
||||
filtered = filtered.filter((coupon) => statusFilters.includes(getStatus(coupon)))
|
||||
}
|
||||
return filtered
|
||||
}, [coupons, statusFilters])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}, [refetch])
|
||||
|
||||
useManualRefresh(() => { refetch() })
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<Loader2 size={32} color="#3b82f6" className="mb-4 animate-spin" />
|
||||
<MyText className="text-lg font-semibold text-gray-600">Loading Reserved Coupons...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white p-4">
|
||||
<CircleAlert size={32} color="#ef4444" className="mb-4" />
|
||||
<MyText className="mb-2 block text-center text-lg font-semibold text-red-600">Failed to load reserved coupons</MyText>
|
||||
<MyButton onClick={() => refetch()} variant="red">
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</MyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row items-center px-4 py-2">
|
||||
<div className="mr-2 flex-1">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search reserved coupons..."
|
||||
/>
|
||||
</div>
|
||||
<MultiSelect
|
||||
label="Filter by Status"
|
||||
value={statusFilters}
|
||||
options={[
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
{ label: 'Redeemed', value: 'redeemed' },
|
||||
]}
|
||||
onValueChange={(value) => setStatusFilters(value as string[])}
|
||||
multiple={true}
|
||||
triggerComponent={({ onClick }: any) => (
|
||||
<button onClick={onClick} className="p-2" aria-label="Filter by status">
|
||||
<ListFilter size={24} color="#6b7280" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<MyFlatList
|
||||
data={filteredCoupons}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => <ReservedCouponItem item={item} />}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className="px-4 pb-4"
|
||||
ListFooterComponent={
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{isFetchingNextPage ? (
|
||||
<MyText className="text-gray-600">Loading more...</MyText>
|
||||
) : hasNextPage ? (
|
||||
<MyButton variant="blue" onClick={() => fetchNextPage()}>
|
||||
Load more
|
||||
</MyButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-20">
|
||||
<TicketPercent size={40} color="#9ca3af" className="mb-4" />
|
||||
<MyText className="mb-2 block text-xl font-semibold text-gray-600">No Reserved Coupons Found</MyText>
|
||||
{searchQuery ? (
|
||||
<MyButton onClick={() => setSearchQuery('')} variant="blue" className="mt-4 bg-gray-500">
|
||||
<MyText className="font-semibold text-white">Clear Search</MyText>
|
||||
</MyButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Coupons() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<'general' | 'reserved'>('general')
|
||||
|
||||
if (location.pathname !== '/dashboard/coupons') {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-1 flex-col bg-white">
|
||||
<div className="flex flex-row border-b border-gray-200 bg-white">
|
||||
{([
|
||||
{ key: 'general', title: 'General' },
|
||||
{ key: 'reserved', title: 'Reserved' },
|
||||
] as const).map((r) => (
|
||||
<button
|
||||
key={r.key}
|
||||
onClick={() => setTab(r.key)}
|
||||
className={`flex-1 border-b-2 py-3 text-center text-[15px] font-medium ${tab === r.key ? 'border-[#F83758] text-[#F83758]' : 'border-transparent text-gray-500'}`}
|
||||
>
|
||||
{r.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col pb-24">
|
||||
{tab === 'general' ? <GeneralTab /> : <ReservedTab />}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/coupons/new' })}
|
||||
aria-label="Create coupon"
|
||||
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
|
||||
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
|
||||
>
|
||||
<Plus size={32} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
apps/admin-web/src/routes/dashboard.customize-app.ordering.tsx
Normal file
301
apps/admin-web/src/routes/dashboard.customize-app.ordering.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, Image as ImageIcon, XCircle, ChevronLeft, CircleAlert, Package } from 'lucide-react'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { StoreProductCard } from '@packages/shared'
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/customize-app/ordering')({
|
||||
component: AllItemsOrder,
|
||||
})
|
||||
|
||||
type Product = Pick<StoreProductCard, 'id' | 'name' | 'images' | 'isOutOfStock'>
|
||||
|
||||
const itemStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 60,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: '#e5e7eb',
|
||||
padding: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
}
|
||||
|
||||
const activeItemStyle: React.CSSProperties = {
|
||||
...itemStyle,
|
||||
borderColor: '#3b82f6',
|
||||
boxShadow: '0 4px 8px rgba(59,130,246,0.3)',
|
||||
}
|
||||
|
||||
const ProductItem: React.FC<DragOrderItemProps<Product>> = ({
|
||||
item,
|
||||
isActive,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
|
||||
const active = isActive || isDragging
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...(active ? activeItemStyle : itemStyle),
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: item.isOutOfStock ? 0.6 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
}}
|
||||
>
|
||||
<div {...attributes} {...listeners} style={{ marginRight: 8, padding: 2, cursor: 'grab', touchAction: 'none' }}>
|
||||
<GripVertical
|
||||
size={24}
|
||||
color={active ? '#3b82f6' : '#9ca3af'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.images?.[0] ? (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }}
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ImageIcon size={24} color="#9ca3af" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<MyText style={{ fontSize: 13, color: '#111827', fontWeight: 500, flex: 1, marginRight: 4 }} numberOfLines={1}>
|
||||
{item.name.length > 30 ? item.name.substring(0, 30) + '...' : item.name}
|
||||
</MyText>
|
||||
|
||||
{item.isOutOfStock && (
|
||||
<XCircle size={16} color="#dc2626" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AllItemsOrder() {
|
||||
const queryClient = useQueryClient()
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery()
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation()
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
useEffect(() => {
|
||||
if (allProducts?.products) {
|
||||
const allItemsOrderConstant = constants?.find((c) => c.key === 'allItemsOrder')
|
||||
|
||||
let orderedIds: number[] = []
|
||||
|
||||
if (allItemsOrderConstant) {
|
||||
const value = allItemsOrderConstant.value
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
orderedIds = value.map((id: any) => parseInt(id))
|
||||
} else if (typeof value === 'string') {
|
||||
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
|
||||
}
|
||||
}
|
||||
|
||||
const productMap = new Map(allProducts.products.map((p) => [p.id, p]))
|
||||
|
||||
const sortedProducts: Product[] = []
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const product = productMap.get(id)
|
||||
if (product) {
|
||||
sortedProducts.push({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
images: product.images || [],
|
||||
isOutOfStock: product.isOutOfStock || false,
|
||||
})
|
||||
productMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const product of productMap.values()) {
|
||||
sortedProducts.push({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
images: product.images || [],
|
||||
isOutOfStock: product.isOutOfStock || false,
|
||||
})
|
||||
}
|
||||
|
||||
setProducts(sortedProducts)
|
||||
}
|
||||
}, [constants, allProducts])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
setProducts((prev) => {
|
||||
const oldIndex = prev.findIndex((p) => p.id === active.id)
|
||||
const newIndex = prev.findIndex((p) => p.id === over.id)
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
setHasChanges(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
const productIds = products.map((p) => p.id)
|
||||
|
||||
updateConstants.mutate(
|
||||
{
|
||||
constants: [{
|
||||
key: 'allItemsOrder',
|
||||
value: productIds,
|
||||
}],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasChanges(false)
|
||||
window.alert('All items order updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
window.alert('Failed to update items order. Please try again.')
|
||||
console.error('Update all items order error:', error)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingConstants || isLoadingProducts) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-center text-gray-500">
|
||||
{isLoadingConstants ? 'Loading order...' : 'Loading products...'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (constantsError || productsError) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<CircleAlert size={64} color="#ef4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
{constantsError ? 'Failed to load order' : 'Failed to load products'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Go Back</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<MyText className="text-xl font-bold text-gray-900">All Items Order</MyText>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateConstants.isPending}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MyText className={`font-semibold ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'text-white'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Package size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No products available
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mx-4 mb-2 mt-2 rounded-lg bg-blue-50 px-4 py-2">
|
||||
<MyText className="text-center text-xs text-blue-700">
|
||||
Long press and drag to reorder • {products.length} items
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-3 pb-5">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={products.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
{products.map((product) => (
|
||||
<ProductItem key={product.id} item={product} drag={() => {}} isActive={false} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
apps/admin-web/src/routes/dashboard.customize-app.popular.tsx
Normal file
413
apps/admin-web/src/routes/dashboard.customize-app.popular.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, Image as ImageIcon, Trash2, ChevronLeft, CircleAlert, Package, Plus } from 'lucide-react'
|
||||
import { AppContainer, p as MyText, BottomDialog } from 'web-components'
|
||||
import ProductsSelector from '../components/ProductsSelector'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { StoreProductCard } from '@packages/shared'
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/customize-app/popular')({
|
||||
component: CustomizePopularItems,
|
||||
})
|
||||
|
||||
type PopularProduct = Omit<
|
||||
StoreProductCard,
|
||||
'price' | 'marketPrice' | 'unitNotation' | 'images'
|
||||
> & {
|
||||
price: number
|
||||
marketPrice: number | null
|
||||
images: string[] | null
|
||||
storeId: number | null
|
||||
nextDeliveryDate: string | null
|
||||
}
|
||||
|
||||
interface ProductItemProps extends DragOrderItemProps<PopularProduct> {
|
||||
onDelete: (id: number) => void
|
||||
}
|
||||
|
||||
const ProductItem: React.FC<ProductItemProps> = ({
|
||||
item,
|
||||
isActive,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
|
||||
const active = isActive || isDragging
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
marginLeft: 16,
|
||||
marginRight: 16,
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`bg-white p-4 rounded-xl border ${
|
||||
active
|
||||
? 'shadow-xl border-blue-500 z-50'
|
||||
: 'shadow-sm border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
<div {...attributes} {...listeners} className="mr-4" style={{ cursor: 'grab', touchAction: 'none' }}>
|
||||
<GripVertical
|
||||
size={24}
|
||||
color={active ? '#3b82f6' : '#9ca3af'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.images?.[0] ? (
|
||||
<img
|
||||
src={item.images[0]}
|
||||
className="mr-4 h-12 w-12 rounded-lg object-cover"
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
|
||||
<ImageIcon size={20} color="#9ca3af" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<MyText className="text-base font-semibold text-gray-900" numberOfLines={2}>
|
||||
{item.name}
|
||||
</MyText>
|
||||
<MyText className="mt-1 text-sm font-bold text-green-600">
|
||||
₹{item.price}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="ml-2 p-2"
|
||||
>
|
||||
<Trash2 size={20} color="#ef4444" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizePopularItems() {
|
||||
const queryClient = useQueryClient()
|
||||
const [popularProducts, setPopularProducts] = useState<PopularProduct[]>([])
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [selectedProductIds, setSelectedProductIds] = useState<number[]>([])
|
||||
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
|
||||
const { data: allProducts, isLoading: isLoadingProducts, error: productsError } = trpc.common.product.getAllProductsSummary.useQuery()
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation()
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
useEffect(() => {
|
||||
if (constants && allProducts?.products) {
|
||||
const popularItemsConstant = constants.find((c) => c.key === 'popularItems')
|
||||
|
||||
let popularIds: number[] = []
|
||||
|
||||
if (popularItemsConstant) {
|
||||
const value = popularItemsConstant.value
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
popularIds = value.map((id: any) => parseInt(id))
|
||||
} else if (typeof value === 'string') {
|
||||
popularIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
|
||||
}
|
||||
|
||||
const popularProds: PopularProduct[] = []
|
||||
|
||||
for (const id of popularIds) {
|
||||
const product = allProducts.products.find((p) => p.id === id)
|
||||
if (product) {
|
||||
popularProds.push(product as PopularProduct)
|
||||
}
|
||||
}
|
||||
|
||||
setPopularProducts(popularProds)
|
||||
}
|
||||
}
|
||||
}, [constants, allProducts])
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
setPopularProducts((prev) => {
|
||||
const oldIndex = prev.findIndex((p) => p.id === active.id)
|
||||
const newIndex = prev.findIndex((p) => p.id === over.id)
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
setHasChanges(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (productId: number) => {
|
||||
if (window.confirm('Are you sure you want to remove this product from popular items?')) {
|
||||
setPopularProducts((prev) => prev.filter((p) => p.id !== productId))
|
||||
setHasChanges(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddProduct = () => {
|
||||
if (selectedProductIds.length > 0) {
|
||||
const newProducts = selectedProductIds
|
||||
.map((id) => allProducts?.products.find((p) => p.id === id))
|
||||
.filter((product): product is NonNullable<typeof product> =>
|
||||
product !== undefined && !popularProducts.find((p) => p.id === product.id)
|
||||
)
|
||||
|
||||
if (newProducts.length > 0) {
|
||||
setPopularProducts((prev) => [...prev, ...newProducts as PopularProduct[]])
|
||||
setHasChanges(true)
|
||||
}
|
||||
|
||||
setSelectedProductIds([])
|
||||
setShowAddDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const popularIds = popularProducts.map((p) => p.id)
|
||||
|
||||
updateConstants.mutate(
|
||||
{
|
||||
constants: [{
|
||||
key: 'popularItems',
|
||||
value: popularIds,
|
||||
}],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasChanges(false)
|
||||
window.alert('Popular items updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
window.alert('Failed to update popular items. Please try again.')
|
||||
console.error('Update popular items error:', error)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const availableProducts = (allProducts?.products || []).filter(
|
||||
(product) => !popularProducts.find((p) => p.id === product.id)
|
||||
)
|
||||
|
||||
if (isLoadingConstants || isLoadingProducts) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
|
||||
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-center text-gray-500">
|
||||
{isLoadingConstants ? 'Loading constants...' : 'Loading products...'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (constantsError || productsError) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
|
||||
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<CircleAlert size={64} color="#ef4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
{constantsError ? 'Failed to load constants' : 'Failed to load products'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Go Back</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<MyText className="text-xl font-bold text-gray-900">Popular Items</MyText>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateConstants.isPending}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MyText className={`font-semibold ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'text-white'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{popularProducts.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Package size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No popular items configured
|
||||
</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-400">
|
||||
Add products to display as popular items
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col pb-20">
|
||||
<div className="bg-blue-50 px-4 py-2 mb-2">
|
||||
<MyText className="text-center text-xs text-blue-700">
|
||||
Long press an item to drag and reorder
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={popularProducts.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
{popularProducts.map((product) => (
|
||||
<ProductItem
|
||||
key={product.id}
|
||||
item={product}
|
||||
drag={() => {}}
|
||||
isActive={false}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed bottom-12 right-6 z-50">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="rounded-full bg-blue-600 p-4 shadow-lg"
|
||||
>
|
||||
<Plus size={24} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BottomDialog
|
||||
open={showAddDialog}
|
||||
onClose={() => {
|
||||
setShowAddDialog(false)
|
||||
setSelectedProductIds([])
|
||||
}}
|
||||
>
|
||||
<div className="px-4 pb-8 pt-2">
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<div className="mb-4 h-1.5 w-12 rounded-full bg-gray-200" />
|
||||
<MyText className="text-lg font-bold text-gray-900">
|
||||
Add Popular Items
|
||||
</MyText>
|
||||
<MyText className="text-sm text-gray-500">
|
||||
Select products to add to popular items
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{availableProducts.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Package size={48} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-gray-500">
|
||||
All products are already in popular items
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ProductsSelector
|
||||
value={selectedProductIds}
|
||||
onChange={(val) => setSelectedProductIds(val as number[])}
|
||||
multiple={true}
|
||||
label="Select Products"
|
||||
placeholder="Choose products..."
|
||||
labelFormat={(product) => `${product.productName} - ₹${product.price}`}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex flex-row gap-3">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(false)}
|
||||
className="flex-1 rounded-lg bg-gray-100 p-3"
|
||||
>
|
||||
<MyText className="text-center font-semibold text-gray-700">
|
||||
Cancel
|
||||
</MyText>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddProduct}
|
||||
disabled={selectedProductIds.length === 0}
|
||||
className={`flex-1 rounded-lg p-3 ${
|
||||
selectedProductIds.length > 0 ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MyText className="text-center font-semibold text-white">
|
||||
{selectedProductIds.length > 0
|
||||
? `Add ${selectedProductIds.length} Product${selectedProductIds.length > 1 ? 's' : ''}`
|
||||
: 'Add Products'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
271
apps/admin-web/src/routes/dashboard.customize-app.tsx
Normal file
271
apps/admin-web/src/routes/dashboard.customize-app.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Formik } from 'formik'
|
||||
import { Pencil, ChevronRight, ArrowDownUp } from 'lucide-react'
|
||||
import { p as MyText, pInput as MyTextInput, AppContainer, Checkbox } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/customize-app')({
|
||||
component: CustomizeApp,
|
||||
})
|
||||
|
||||
type ConstantFormData = Record<string, any>
|
||||
|
||||
const CONST_LABELS: Record<string, string> = {
|
||||
minRegularOrderValue: 'Minimum Regular Order Value',
|
||||
freeDeliveryThreshold: 'Free Delivery Threshold',
|
||||
deliveryCharge: 'Delivery Charge',
|
||||
flashFreeDeliveryThreshold: 'Flash Free Delivery Threshold',
|
||||
flashDeliveryCharge: 'Flash Delivery Charge',
|
||||
platformFeePercent: 'Platform Fee Percent',
|
||||
taxRate: 'Tax Rate',
|
||||
minOrderAmountForCoupon: 'Minimum Order Amount for Coupon',
|
||||
maxCouponDiscount: 'Maximum Coupon Discount',
|
||||
flashDeliverySlotId: 'Flash Delivery Slot ID',
|
||||
readableOrderId: 'Readable Order ID',
|
||||
versionNum: 'Version Number',
|
||||
playStoreUrl: 'Play Store URL',
|
||||
appStoreUrl: 'App Store URL',
|
||||
popularItems: 'Popular Items',
|
||||
allItemsOrder: 'All Items Order',
|
||||
isFlashDeliveryEnabled: 'Enable Flash Delivery',
|
||||
supportMobile: 'Support Mobile',
|
||||
supportEmail: 'Support Email',
|
||||
}
|
||||
|
||||
const CONST_VISIBILITY: Record<string, boolean> = {
|
||||
minRegularOrderValue: true,
|
||||
freeDeliveryThreshold: true,
|
||||
deliveryCharge: true,
|
||||
flashFreeDeliveryThreshold: true,
|
||||
flashDeliveryCharge: true,
|
||||
platformFeePercent: true,
|
||||
taxRate: false,
|
||||
minOrderAmountForCoupon: true,
|
||||
maxCouponDiscount: false,
|
||||
flashDeliverySlotId: true,
|
||||
readableOrderId: false,
|
||||
versionNum: true,
|
||||
playStoreUrl: true,
|
||||
appStoreUrl: true,
|
||||
popularItems: true,
|
||||
allItemsOrder: true,
|
||||
isFlashDeliveryEnabled: true,
|
||||
supportMobile: true,
|
||||
supportEmail: true,
|
||||
tester: false,
|
||||
}
|
||||
|
||||
interface ConstantInputProps {
|
||||
constantKey: string
|
||||
value: any
|
||||
setFieldValue: (field: string, value: any) => void
|
||||
router: any
|
||||
}
|
||||
|
||||
const ConstantInput: React.FC<ConstantInputProps> = ({ constantKey, value, setFieldValue, router }) => {
|
||||
const fieldName = constantKey
|
||||
|
||||
if (constantKey === 'popularItems') {
|
||||
return (
|
||||
<div>
|
||||
<MyText className="mb-2 text-sm font-medium text-gray-700">
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/customize-app/popular')}
|
||||
className="flex flex-row items-center justify-center rounded-lg border-2 border-dashed border-blue-200 bg-blue-50 p-4"
|
||||
>
|
||||
<Pencil size={20} color="#3b82f6" className="mr-2" />
|
||||
<MyText className="font-medium text-blue-700">
|
||||
Manage Popular Items ({Array.isArray(value) ? value.length : 0} items)
|
||||
</MyText>
|
||||
<ChevronRight size={20} color="#3b82f6" className="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (constantKey === 'allItemsOrder') {
|
||||
return (
|
||||
<div>
|
||||
<MyText className="mb-2 text-sm font-medium text-gray-700">
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/customize-app/ordering')}
|
||||
className="flex flex-row items-center justify-center rounded-lg border-2 border-dashed border-green-200 bg-green-50 p-4"
|
||||
>
|
||||
<ArrowDownUp size={20} color="#16a34a" className="mr-2" />
|
||||
<MyText className="font-medium text-green-700">
|
||||
Manage All Visible Items ({Array.isArray(value) ? value.length : 0} items)
|
||||
</MyText>
|
||||
<ChevronRight size={20} color="#16a34a" className="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<div>
|
||||
<MyText className="mb-2 text-sm font-medium text-gray-700">
|
||||
{CONST_LABELS[constantKey] || constantKey}
|
||||
</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onPress={() => setFieldValue(fieldName, !value)}
|
||||
size={28}
|
||||
/>
|
||||
<MyText className="ml-3 text-gray-700">
|
||||
{value ? 'Enabled' : 'Disabled'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
value={value.toString()}
|
||||
onChange={(e) => {
|
||||
const numValue = parseFloat(e.target.value)
|
||||
setFieldValue(fieldName, isNaN(numValue) ? 0 : numValue)
|
||||
}}
|
||||
type="number"
|
||||
placeholder="Enter number"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
value={value.join(', ')}
|
||||
onChange={(e) => {
|
||||
const arrayValue = e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
setFieldValue(fieldName, arrayValue)
|
||||
}}
|
||||
placeholder="Enter comma separated values"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MyTextInput
|
||||
topLabel={CONST_LABELS[constantKey] || constantKey}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setFieldValue(fieldName, e.target.value)
|
||||
}}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeApp() {
|
||||
const navigate = useNavigate()
|
||||
const router = {
|
||||
push: (to: string) => navigate({ to: to as any }),
|
||||
}
|
||||
const { data: constants, isLoading: isLoadingConstants, refetch } = trpc.admin.const.getConstants.useQuery()
|
||||
const { mutate: updateConstants, isPending: isUpdating } = trpc.admin.const.updateConstants.useMutation()
|
||||
|
||||
const handleSubmit = (values: ConstantFormData) => {
|
||||
const changedConstants = (constants || []).filter((constant) => {
|
||||
const nextValue = values[constant.key]
|
||||
return JSON.stringify(nextValue) !== JSON.stringify(constant.value)
|
||||
}).map((constant) => ({
|
||||
key: constant.key,
|
||||
value: values[constant.key],
|
||||
}))
|
||||
|
||||
if (changedConstants.length === 0) {
|
||||
window.alert('No constants were modified.')
|
||||
return
|
||||
}
|
||||
|
||||
updateConstants(
|
||||
{ constants: changedConstants },
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
window.alert(`Updated ${response.updatedCount} constants successfully.`)
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
window.alert('Failed to update constants. Please try again.')
|
||||
console.error('Update constants error:', error)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingConstants) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-500">Loading constants...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!constants) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-500">Failed to load constants</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const initialValues: ConstantFormData = constants.reduce((acc, constant) => {
|
||||
acc[constant.key] = constant.value ?? ''
|
||||
return acc
|
||||
}, {} as ConstantFormData)
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50 p-4">
|
||||
<MyText className="mb-4 text-xl font-bold">Customize App Constants</MyText>
|
||||
<MyText className="mb-6 text-gray-600">
|
||||
Modify application constants. Changes will take effect immediately.
|
||||
</MyText>
|
||||
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
{({ handleSubmit, values, setFieldValue }) => (
|
||||
<div>
|
||||
{constants.map((constant) => {
|
||||
if (!CONST_VISIBILITY[constant.key]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={constant.key} className="mb-4">
|
||||
<ConstantInput
|
||||
constantKey={constant.key}
|
||||
value={values[constant.key]}
|
||||
setFieldValue={setFieldValue}
|
||||
router={router}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isUpdating}
|
||||
className={`mt-6 rounded-lg bg-blue-500 p-4 ${isUpdating ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<MyText className="text-center font-semibold text-white">
|
||||
{isUpdating ? 'Updating...' : 'Save Changes'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
305
apps/admin-web/src/routes/dashboard.index.tsx
Normal file
305
apps/admin-web/src/routes/dashboard.index.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
ShoppingBag,
|
||||
Route as RouteIcon,
|
||||
CalendarClock,
|
||||
CirclePlus,
|
||||
TriangleAlert,
|
||||
Package,
|
||||
DollarSign,
|
||||
Tag,
|
||||
Shapes,
|
||||
Store,
|
||||
TicketPercent,
|
||||
Settings,
|
||||
Users,
|
||||
Megaphone,
|
||||
ReceiptText,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { p as MyText } from 'web-components'
|
||||
import { trpc } from '../lib/trpc-client'
|
||||
import { useNavigationTarget } from '../lib/navigation-target'
|
||||
import { theme } from '../lib/theme'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/')({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
interface MenuItem {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
description?: string
|
||||
route: string
|
||||
category: 'quick' | 'products' | 'orders' | 'marketing' | 'settings' | 'users'
|
||||
iconColor?: string
|
||||
iconBg?: string
|
||||
badgeCount?: number
|
||||
onPress?: () => void
|
||||
testID?: string
|
||||
}
|
||||
|
||||
const MenuItemComponent: React.FC<{ item: MenuItem; go: (item: MenuItem) => void }> = ({
|
||||
item,
|
||||
go,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => go(item)}
|
||||
className="mb-3 flex w-full flex-row items-center rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50 active:bg-gray-50"
|
||||
>
|
||||
<span
|
||||
className="mr-4 flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: item.iconBg }}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<MyText className="mb-0.5 text-base font-bold text-gray-900">{item.title}</MyText>
|
||||
{item.description && <MyText className="text-xs text-gray-500">{item.description}</MyText>}
|
||||
</span>
|
||||
{item.badgeCount ? (
|
||||
<span className="rounded-full bg-red-500 px-2 py-1">
|
||||
<MyText className="text-xs font-bold text-white">{item.badgeCount}</MyText>
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight size={24} color="#D1D5DB" className="ml-2" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { setNavigationTarget } = useNavigationTarget()
|
||||
|
||||
const { data: essentialsData } = trpc.admin.user.getEssentials.useQuery()
|
||||
|
||||
const go = (item: MenuItem) => {
|
||||
if (item.onPress) {
|
||||
item.onPress()
|
||||
} else {
|
||||
navigate({ to: item.route as any })
|
||||
}
|
||||
}
|
||||
|
||||
const handleManageOrdersPress = () => {
|
||||
setNavigationTarget('/dashboard/orders/list')
|
||||
navigate({ to: '/dashboard/orders' })
|
||||
}
|
||||
|
||||
const handleDeliverySequencesPress = () => {
|
||||
setNavigationTarget('/dashboard/orders/sequence')
|
||||
navigate({ to: '/dashboard/orders' })
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
title: 'Manage Orders',
|
||||
icon: <ShoppingBag size={24} color="#10B981" />,
|
||||
description: 'View and manage customer orders',
|
||||
route: '/dashboard/orders',
|
||||
category: 'orders',
|
||||
iconColor: '#10B981',
|
||||
iconBg: '#D1FAE5',
|
||||
onPress: handleManageOrdersPress,
|
||||
},
|
||||
{
|
||||
title: 'Delivery Sequences',
|
||||
icon: <RouteIcon size={24} color="#8B5CF6" />,
|
||||
description: 'Plan and optimize delivery routes',
|
||||
route: '/dashboard/orders/sequence',
|
||||
category: 'orders',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#EDE9FE',
|
||||
onPress: handleDeliverySequencesPress,
|
||||
},
|
||||
{
|
||||
title: 'Delivery Slots',
|
||||
icon: <CalendarClock size={24} color="#06B6D4" />,
|
||||
description: 'Configure delivery times',
|
||||
route: '/dashboard/slots',
|
||||
category: 'quick',
|
||||
iconColor: '#06B6D4',
|
||||
iconBg: '#CFFAFE',
|
||||
testID: 'delivery-slots-menu-item',
|
||||
},
|
||||
{
|
||||
title: 'Add Product',
|
||||
icon: <CirclePlus size={24} color={theme.colors.brand500} />,
|
||||
description: 'Create a new product listing',
|
||||
route: '/dashboard/products/new',
|
||||
category: 'quick',
|
||||
iconColor: theme.colors.brand500,
|
||||
iconBg: theme.colors.brand50,
|
||||
},
|
||||
{
|
||||
title: 'Complaints',
|
||||
icon: <TriangleAlert size={24} color="#F59E0B" />,
|
||||
description: 'Handle customer complaints',
|
||||
route: '/dashboard/complaints',
|
||||
category: 'quick',
|
||||
iconColor: '#F59E0B',
|
||||
iconBg: '#FEF3C7',
|
||||
badgeCount: essentialsData?.unresolvedComplaints,
|
||||
},
|
||||
{
|
||||
title: 'Products',
|
||||
icon: <Package size={24} color="#6366F1" />,
|
||||
description: 'Manage product catalog',
|
||||
route: '/dashboard/products',
|
||||
category: 'products',
|
||||
iconColor: '#6366F1',
|
||||
iconBg: '#E0E7FF',
|
||||
},
|
||||
{
|
||||
title: 'Prices Overview',
|
||||
icon: <DollarSign size={24} color="#059669" />,
|
||||
description: 'Update product pricing',
|
||||
route: '/dashboard/prices',
|
||||
category: 'products',
|
||||
iconColor: '#059669',
|
||||
iconBg: '#ECFDF5',
|
||||
},
|
||||
{
|
||||
title: 'Product Tags',
|
||||
icon: <Tag size={24} color="#EC4899" />,
|
||||
description: 'Organize with tags',
|
||||
route: '/dashboard/product-tags',
|
||||
category: 'products',
|
||||
iconColor: '#EC4899',
|
||||
iconBg: '#FCE7F3',
|
||||
},
|
||||
{
|
||||
title: 'Product Groupings',
|
||||
icon: <Shapes size={24} color="#8B5CF6" />,
|
||||
description: 'Group products together',
|
||||
route: '/dashboard/product-groupings',
|
||||
category: 'products',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
{
|
||||
title: 'Stores',
|
||||
icon: <Store size={24} color="#14B8A6" />,
|
||||
description: 'Manage store locations',
|
||||
route: '/dashboard/stores',
|
||||
category: 'settings',
|
||||
iconColor: '#14B8A6',
|
||||
iconBg: '#CCFBF1',
|
||||
},
|
||||
{
|
||||
title: 'Coupons',
|
||||
icon: <TicketPercent size={24} color="#F97316" />,
|
||||
description: 'Create discount coupons',
|
||||
route: '/dashboard/coupons',
|
||||
category: 'marketing',
|
||||
iconColor: '#F97316',
|
||||
iconBg: '#FFEDD5',
|
||||
},
|
||||
{
|
||||
title: 'App Constants',
|
||||
icon: <Settings size={24} color="#7C3AED" />,
|
||||
description: 'Customize app settings',
|
||||
route: '/dashboard/customize-app',
|
||||
category: 'settings',
|
||||
iconColor: '#7C3AED',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
{
|
||||
title: 'User Management',
|
||||
icon: <Users size={24} color="#0EA5E9" />,
|
||||
description: 'View and manage all users',
|
||||
route: '/dashboard/users',
|
||||
category: 'users',
|
||||
iconColor: '#0EA5E9',
|
||||
iconBg: '#E0F2FE',
|
||||
},
|
||||
{
|
||||
title: 'Send Notifications',
|
||||
icon: <Megaphone size={24} color="#8B5CF6" />,
|
||||
description: 'Send push notifications to users',
|
||||
route: '/dashboard/notifications',
|
||||
category: 'users',
|
||||
iconColor: '#8B5CF6',
|
||||
iconBg: '#F3E8FF',
|
||||
},
|
||||
]
|
||||
|
||||
const quickActions = menuItems.filter((item) => item.category === 'quick')
|
||||
|
||||
const categories = [
|
||||
{ key: 'orders', title: 'Orders', icon: <ReceiptText size={18} color={theme.colors.gray600} /> },
|
||||
{
|
||||
key: 'products',
|
||||
title: 'Products & Inventory',
|
||||
icon: <Package size={18} color={theme.colors.gray600} />,
|
||||
},
|
||||
{
|
||||
key: 'marketing',
|
||||
title: 'Marketing & Promotions',
|
||||
icon: <Megaphone size={18} color={theme.colors.gray600} />,
|
||||
},
|
||||
{ key: 'users', title: 'User Management', icon: <Users size={18} color={theme.colors.gray600} /> },
|
||||
{
|
||||
key: 'settings',
|
||||
title: 'Settings & Configuration',
|
||||
icon: <Settings size={18} color={theme.colors.gray600} />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex-1 bg-gray-50">
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-4 mt-2 px-6">
|
||||
<MyText className="mb-4 text-lg font-bold text-gray-900">Quick Actions</MyText>
|
||||
<div className="flex flex-row flex-wrap gap-3">
|
||||
{quickActions.map((item) => (
|
||||
<button
|
||||
key={`quick-${item.route}`}
|
||||
data-testid={item.testID}
|
||||
aria-label={item.testID}
|
||||
onClick={() => go(item)}
|
||||
className="flex flex-1 flex-col items-center rounded-xl border border-gray-100 bg-white p-3 shadow-sm transition-colors hover:bg-gray-50 active:bg-gray-50"
|
||||
>
|
||||
<span
|
||||
className="relative mb-2 flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: item.iconBg }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.badgeCount ? (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1">
|
||||
<MyText className="text-[10px] font-bold text-white">{item.badgeCount}</MyText>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<MyText className="text-center text-xs font-bold text-gray-900" numberOfLines={2}>{item.title}</MyText>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categorized Menu Items */}
|
||||
<div className="px-6 pb-6">
|
||||
{categories.map((category) => {
|
||||
const categoryItems = menuItems.filter((item) => item.category === category.key)
|
||||
if (categoryItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category.key} className="mb-6">
|
||||
<div className="mb-3 flex flex-row items-center">
|
||||
<span className="mr-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100">
|
||||
{category.icon}
|
||||
</span>
|
||||
<MyText className="text-base font-bold text-gray-700">{category.title}</MyText>
|
||||
</div>
|
||||
{categoryItems.map((item) => (
|
||||
<MenuItemComponent key={`menu-${item.route}`} item={item} go={go} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
apps/admin-web/src/routes/dashboard.notifications.tsx
Normal file
168
apps/admin-web/src/routes/dashboard.notifications.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { AppContainer, p as MyText, pInput as MyTextInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { UserMiniInfo } from '@packages/shared'
|
||||
import { MultiSelect } from '../components/MultiSelect'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/notifications')({
|
||||
component: SendNotifications,
|
||||
})
|
||||
|
||||
type User = Omit<UserMiniInfo, 'name'> & {
|
||||
name: string | null
|
||||
isEligibleForNotif: boolean
|
||||
}
|
||||
|
||||
function SendNotifications() {
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const { data: usersData, isLoading: isLoadingUsers } = trpc.admin.user.getUsersForNotification.useQuery({
|
||||
search: searchQuery,
|
||||
})
|
||||
|
||||
const sendNotification = trpc.admin.user.sendNotification.useMutation({
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Notification sent successfully!')
|
||||
setSelectedUserIds([])
|
||||
setTitle('')
|
||||
setMessage('')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Error: ${error.message || 'Failed to send notification'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const eligibleUsers = usersData?.users.filter((u: User) => u.isEligibleForNotif) || []
|
||||
|
||||
const dropdownOptions = eligibleUsers.map((user: User) => ({
|
||||
label: `${user.mobile || 'No Mobile'}${user.name ? ` - ${user.name}` : ''}`,
|
||||
value: user.id,
|
||||
}))
|
||||
|
||||
const handleSend = async () => {
|
||||
if (title.trim().length === 0) {
|
||||
window.alert('Error: Please enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
if (message.trim().length === 0) {
|
||||
window.alert('Error: Please enter a message')
|
||||
return
|
||||
}
|
||||
|
||||
const isSendingToAll = selectedUserIds.length === 0
|
||||
if (isSendingToAll) {
|
||||
const confirmed = window.confirm(
|
||||
'Send to All Users? This will send the notification to all users with push tokens. Continue?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
await sendNotification.mutateAsync({
|
||||
userIds: selectedUserIds,
|
||||
title: title.trim(),
|
||||
text: message.trim(),
|
||||
})
|
||||
} catch (error: any) {
|
||||
window.alert(`Error: ${error.message || 'Failed to send notification'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedUserIds.length === 0) return 'All Users'
|
||||
if (selectedUserIds.length === 1) {
|
||||
const user = eligibleUsers.find((u: User) => u.id === selectedUserIds[0])
|
||||
return user ? `${user.mobile}${user.name ? ` - ${user.name}` : ''}` : '1 user selected'
|
||||
}
|
||||
return `${selectedUserIds.length} users selected`
|
||||
}
|
||||
|
||||
if (isLoadingUsers) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading users...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const isSendDisabled = sendNotification.isPending || title.trim().length === 0 || message.trim().length === 0
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex-row flex items-center border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="ml-2 text-xl font-bold text-gray-900">Send Notifications</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<MyText className="mb-3 text-base font-bold text-gray-900">Title</MyText>
|
||||
<MyTextInput
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter notification title..."
|
||||
className="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<MyText className="mb-3 text-base font-bold text-gray-900">Message</MyText>
|
||||
<MyTextInput
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Enter notification message..."
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
className="text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<MyText className="mb-3 text-base font-bold text-gray-900">Select Users (Optional)</MyText>
|
||||
<MultiSelect
|
||||
label="Select Users"
|
||||
value={selectedUserIds}
|
||||
options={dropdownOptions}
|
||||
onValueChange={(value) => setSelectedUserIds(value as number[])}
|
||||
onSearch={(query: string) => setSearchQuery(query)}
|
||||
/>
|
||||
<MyText className="mt-2 text-sm text-gray-500">
|
||||
{getDisplayText()}
|
||||
</MyText>
|
||||
<MyText className="mt-1 text-xs text-blue-600">
|
||||
Leave empty to send to all users
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isSendDisabled}
|
||||
className={`items-center rounded-xl py-4 shadow-sm ${
|
||||
isSendDisabled ? 'bg-gray-300' : 'bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<MyText className="text-base font-bold text-white">
|
||||
{sendNotification.isPending ? 'Sending...' : selectedUserIds.length === 0 ? 'Send to All Users' : 'Send Notification'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
824
apps/admin-web/src/routes/dashboard.orders.$id.tsx
Normal file
824
apps/admin-web/src/routes/dashboard.orders.$id.tsx
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText, BottomDialog, pInput as MyInput } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import { theme } from '@/lib/theme'
|
||||
import {
|
||||
Clock,
|
||||
Zap,
|
||||
CircleAlert,
|
||||
Check,
|
||||
Package,
|
||||
Truck,
|
||||
ChevronRight,
|
||||
User,
|
||||
Phone,
|
||||
Mail,
|
||||
MapPin,
|
||||
CircleX,
|
||||
TicketPercent,
|
||||
StickyNote,
|
||||
Info,
|
||||
Percent,
|
||||
Banknote
|
||||
} from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import CancelOrderDialog from '../components/CancelOrderDialog'
|
||||
import { UserIncidentsView } from '../components/UserIncidentsView'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/orders/$id')({
|
||||
component: OrderDetails,
|
||||
})
|
||||
|
||||
function OrderDetails() {
|
||||
const { id } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const [generateCouponDialogOpen, setGenerateCouponDialogOpen] =
|
||||
useState(false)
|
||||
const [initiateRefundDialogOpen, setInitiateRefundDialogOpen] =
|
||||
useState(false)
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
|
||||
const [refundType, setRefundType] = useState<'percent' | 'amount'>('percent')
|
||||
const [refundValue, setRefundValue] = useState('100')
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set())
|
||||
|
||||
const {
|
||||
data: orderData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.order.getOrderDetails.useQuery(
|
||||
{ orderId: id ? parseInt(id) : 0 },
|
||||
{ enabled: !!id }
|
||||
)
|
||||
|
||||
const generateCouponMutation =
|
||||
trpc.admin.coupon.generateCancellationCoupon.useMutation({
|
||||
onSuccess: (coupon) => {
|
||||
window.alert(
|
||||
`Success: Refund coupon generated successfully!\n\nCode: ${coupon.couponCode
|
||||
}\nValue: ₹${coupon.flatDiscount}\nExpires: ${coupon.validTill
|
||||
? new Date(coupon.validTill).toLocaleDateString()
|
||||
: 'N/A'
|
||||
}`
|
||||
)
|
||||
setGenerateCouponDialogOpen(false)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(
|
||||
error.message || 'Failed to generate refund coupon'
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const initiateRefundMutation = trpc.admin.payments.initiateRefund.useMutation(
|
||||
{
|
||||
onSuccess: () => {
|
||||
window.alert(
|
||||
'Success: Refund initiated successfully!\n\nAmount: '
|
||||
// `Refund initiated successfully!\n\nAmount: ₹${result.amount}\nStatus: ${result.status}`
|
||||
)
|
||||
setInitiateRefundDialogOpen(false)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to initiate refund')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation({
|
||||
onSuccess: () => {
|
||||
// Refetch order details to get updated packaging status
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to update packaging status')
|
||||
},
|
||||
})
|
||||
|
||||
const removeDeliveryChargeMutation = trpc.admin.order.removeDeliveryCharge.useMutation({
|
||||
onSuccess: () => {
|
||||
window.alert('Success: Delivery charge has been removed')
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to remove delivery charge')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50">
|
||||
<MyText className="font-medium text-gray-500">
|
||||
Loading order details...
|
||||
</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !orderData) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<div className="w-full items-center rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||
<CircleAlert size={48} color="#EF4444" className="mx-auto" />
|
||||
<MyText className="mb-2 mt-4 block text-xl font-bold text-gray-900">
|
||||
Oops!
|
||||
</MyText>
|
||||
<MyText className="mb-6 block text-center text-gray-500">
|
||||
{error?.message || 'Failed to load order details'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="w-full items-center rounded-xl bg-gray-900 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-bold text-white">Go Back</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const order = orderData
|
||||
|
||||
// Calculate subtotal and discount for order summary
|
||||
const subtotal = order.items.reduce((sum, item) => sum + item.amount, 0)
|
||||
const discountAmount = order.discountAmount || 0
|
||||
|
||||
const handleGenerateCoupon = () => {
|
||||
generateCouponMutation.mutate({ orderId: order.id })
|
||||
}
|
||||
|
||||
const handleInitiateRefund = () => {
|
||||
const value = parseFloat(refundValue)
|
||||
if (isNaN(value) || value <= 0) {
|
||||
ErrorToast('Please enter a valid refund value')
|
||||
return
|
||||
}
|
||||
|
||||
const mutationData: any = {
|
||||
orderId: order.id,
|
||||
}
|
||||
|
||||
if (refundType === 'percent') {
|
||||
if (value > 100) {
|
||||
ErrorToast('Refund percentage cannot exceed 100%')
|
||||
return
|
||||
}
|
||||
mutationData.refundPercent = value
|
||||
} else {
|
||||
mutationData.refundAmount = value
|
||||
}
|
||||
|
||||
initiateRefundMutation.mutate(mutationData)
|
||||
setInitiateRefundDialogOpen(false)
|
||||
}
|
||||
|
||||
const handlePackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
|
||||
// Add item to updating set to disable UI
|
||||
setUpdatingItems((prev) => new Set(prev).add(itemId))
|
||||
|
||||
updateItemPackagingMutation.mutate(
|
||||
{
|
||||
orderItemId: itemId,
|
||||
[field]: value,
|
||||
},
|
||||
{
|
||||
onSettled: () => {
|
||||
// Remove item from updating set
|
||||
setUpdatingItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(itemId)
|
||||
return newSet
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return 'text-green-600 bg-green-50 border-green-100'
|
||||
case 'cancelled':
|
||||
return 'text-red-600 bg-red-50 border-red-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
const statusStyle = getStatusColor(order.status)
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
|
||||
<div className="flex-1 pb-32">
|
||||
{/* Order ID & Status Card */}
|
||||
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<MyText className="mb-1 block text-sm text-gray-500">Order ID</MyText>
|
||||
<MyText className="block text-2xl font-bold text-gray-900">
|
||||
#{order.readableId}
|
||||
</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className={`rounded-full border px-3 py-1.5 ${statusStyle}`}>
|
||||
<MyText
|
||||
className={`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]
|
||||
}`}
|
||||
>
|
||||
{order.status}
|
||||
</MyText>
|
||||
</span>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-100 px-2 py-1">
|
||||
<MyText className="text-[10px] font-black uppercase text-amber-700">⚡</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mb-2 flex flex-row items-center rounded-xl bg-gray-50 p-3 ${order.isFlashDelivery ? 'bg-amber-50' : ''}`}>
|
||||
<Clock size={20} color={order.isFlashDelivery ? '#D97706' : theme.colors.brand800} />
|
||||
<MyText className={`ml-2 font-medium ${order.isFlashDelivery ? 'text-amber-800' : 'text-brand-800'}`}>
|
||||
{order.isFlashDelivery ? '1 Hr Delivery:' : 'Delivery at:'} {order.isFlashDelivery
|
||||
? dayjs(order.createdAt).add(30, 'minutes').format('MMM DD, YYYY • h:mm A')
|
||||
: order.slotInfo?.time ? dayjs(order.slotInfo.time).format('MMM DD, YYYY • h:mm A') : 'Not scheduled'}
|
||||
</MyText>
|
||||
</div>
|
||||
{order.isFlashDelivery && (
|
||||
<div className="flex flex-row items-center rounded-xl border border-amber-200 bg-amber-50 p-3">
|
||||
<Zap size={18} color="#D97706" />
|
||||
<MyText className="ml-2 text-sm font-bold text-amber-800">
|
||||
⚡ 1 Hr Delivery • High Priority
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center rounded-xl bg-gray-50 p-3">
|
||||
<Clock size={20} color="#6B7280" />
|
||||
<MyText className="ml-2 font-medium text-gray-600">
|
||||
Placed on {dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancellation Reason */}
|
||||
{order.status === 'cancelled' && order.cancelReason && (
|
||||
<div className="mb-4 rounded-2xl border border-red-100 bg-red-50 p-5">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<CircleX size={18} color="#DC2626" />
|
||||
<MyText className="ml-2 text-sm font-bold text-red-800">
|
||||
Cancellation Reason
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm leading-5 text-red-900">
|
||||
{order.cancelReason}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Progress (Simplified Timeline) */}
|
||||
{order.status !== 'cancelled' && (
|
||||
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<MyText className="mb-4 block text-base font-bold text-gray-900">
|
||||
Order Status
|
||||
</MyText>
|
||||
<div className="flex flex-row items-center justify-between px-2">
|
||||
{/* Placed */}
|
||||
<div className="items-center">
|
||||
<span className="mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100">
|
||||
<Check size={16} color="#10B981" />
|
||||
</span>
|
||||
<MyText className="text-xs font-medium text-gray-900">
|
||||
Placed
|
||||
</MyText>
|
||||
</div>
|
||||
<div className="-mt-6 mx-2 h-0.5 flex-1 bg-green-200" />
|
||||
|
||||
{/* Packaged */}
|
||||
<div className="items-center">
|
||||
<span
|
||||
className={`mb-2 flex h-8 w-8 items-center justify-center rounded-full ${order.isPackaged ? 'bg-green-100' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Package
|
||||
size={16}
|
||||
color={order.isPackaged ? '#10B981' : '#9CA3AF'}
|
||||
/>
|
||||
</span>
|
||||
<MyText
|
||||
className={`text-xs font-medium ${order.isPackaged ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Packaged
|
||||
</MyText>
|
||||
</div>
|
||||
<div
|
||||
className={`-mt-6 mx-2 h-0.5 flex-1 ${order.isPackaged ? 'bg-green-200' : 'bg-gray-100'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Delivered */}
|
||||
<div className="items-center">
|
||||
<span
|
||||
className={`mb-2 flex h-8 w-8 items-center justify-center rounded-full ${order.isDelivered ? 'bg-green-100' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{order.isFlashDelivery && order.isDelivered ? (
|
||||
<Zap size={16} color="#D97706" />
|
||||
) : (
|
||||
<Truck
|
||||
size={16}
|
||||
color={order.isDelivered ? '#10B981' : '#9CA3AF'}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="items-center">
|
||||
<MyText
|
||||
className={`text-xs font-medium ${order.isDelivered ? 'text-gray-900' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{order.isFlashDelivery && order.isDelivered ? 'Flash Delivered' : 'Delivered'}
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<MyText className="mt-0.5 block text-[9px] font-bold text-amber-600">
|
||||
⚡ 30-Min
|
||||
</MyText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer Details */}
|
||||
<button
|
||||
onClick={() => order.userId && navigate({ to: '/dashboard/users/$id', params: { id: String(order.userId) } })}
|
||||
className="mb-4 w-full rounded-2xl border border-gray-100 bg-white p-5 text-left shadow-sm"
|
||||
>
|
||||
<div className="mb-4 flex flex-row items-center justify-between">
|
||||
<MyText className="text-base font-bold text-gray-900">
|
||||
Customer Details
|
||||
</MyText>
|
||||
<ChevronRight size={20} color="#6B7280" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-row items-center">
|
||||
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-blue-50">
|
||||
<User size={20} color="#3B82F6" />
|
||||
</span>
|
||||
<div>
|
||||
<MyText className="block text-sm font-bold text-gray-900">
|
||||
{order.customerName || 'Unknown User'}
|
||||
</MyText>
|
||||
<MyText className="block text-xs text-gray-500">Customer</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-start">
|
||||
<span className="mt-0.5 w-6">
|
||||
<Phone size={16} color="#6B7280" />
|
||||
</span>
|
||||
<MyText className="flex-1 text-sm text-gray-600">
|
||||
{order.customerMobile}
|
||||
</MyText>
|
||||
</div>
|
||||
{order.customerEmail && (
|
||||
<div className="mt-2 flex flex-row items-start">
|
||||
<span className="mt-0.5 w-6">
|
||||
<Mail size={16} color="#6B7280" />
|
||||
</span>
|
||||
<MyText className="flex-1 text-sm text-gray-600">
|
||||
{order.customerEmail}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-row items-start border-t border-gray-50 pt-3">
|
||||
<span className="mt-0.5 w-6">
|
||||
<MapPin size={16} color="#6B7280" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<MyText className="text-sm leading-5 text-gray-600">
|
||||
{order.address.name} {' '}
|
||||
{order.address.line1}
|
||||
{order.address.line2 ? `, ${order.address.line2}` : ''}
|
||||
{`\n${order.address.city}, ${order.address.state} - ${order.address.pincode}`}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<MyText className="mb-4 block text-base font-bold text-gray-900">
|
||||
Items Ordered
|
||||
</MyText>
|
||||
{order.items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex flex-row items-center border-b border-gray-50 py-3 ${index === order.items.length - 1 ? 'border-b-0' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||
<Package size={14} color="#6B7280" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<MyText className="block text-sm font-bold text-gray-900">
|
||||
{item.name}
|
||||
</MyText>
|
||||
<MyText className="block text-xs text-gray-500">
|
||||
{Number(item.quantity)} x {item.unit} × ₹{item.price}
|
||||
</MyText>
|
||||
<div className="mt-2 flex flex-row items-center gap-3">
|
||||
<button
|
||||
className="flex flex-row items-center disabled:opacity-50"
|
||||
onClick={() => handlePackagingToggle(item.id, 'isPackaged', !item.isPackaged)}
|
||||
disabled={updatingItems.has(item.id)}
|
||||
>
|
||||
<Check
|
||||
size={16}
|
||||
color={updatingItems.has(item.id) ? '#F59E0B' : item.isPackaged ? '#10B981' : '#9CA3AF'}
|
||||
/>
|
||||
<MyText className={`ml-1 text-xs ${updatingItems.has(item.id) ? 'text-yellow-700' : item.isPackaged ? 'text-green-700' : 'text-gray-500'}`}>
|
||||
Packaged
|
||||
</MyText>
|
||||
</button>
|
||||
<button
|
||||
className="flex flex-row items-center disabled:opacity-50"
|
||||
onClick={() => handlePackagingToggle(item.id, 'isPackageVerified', !item.isPackageVerified)}
|
||||
disabled={updatingItems.has(item.id)}
|
||||
>
|
||||
<Check
|
||||
size={16}
|
||||
color={updatingItems.has(item.id) ? '#F59E0B' : item.isPackageVerified ? '#10B981' : '#9CA3AF'}
|
||||
/>
|
||||
<MyText className={`ml-1 text-xs ${updatingItems.has(item.id) ? 'text-yellow-700' : item.isPackageVerified ? 'text-green-700' : 'text-gray-500'}`}>
|
||||
Pkg Verified
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<MyText className="text-sm font-bold text-gray-900">
|
||||
₹{item.amount}
|
||||
</MyText>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 border-t border-gray-100 pt-4">
|
||||
<div className="mb-2 flex flex-row items-center justify-between">
|
||||
<MyText className="text-base font-bold text-gray-900">
|
||||
Subtotal ({order.items.length} items)
|
||||
</MyText>
|
||||
<MyText className="text-base font-bold text-gray-900">
|
||||
₹{subtotal}
|
||||
</MyText>
|
||||
</div>
|
||||
{discountAmount > 0 && (
|
||||
<div className="mb-2 flex flex-row items-center justify-between">
|
||||
<MyText className="font-medium text-emerald-600">
|
||||
Discount
|
||||
</MyText>
|
||||
<MyText className="font-medium text-emerald-600">
|
||||
-₹{discountAmount}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
{order.deliveryCharge > 0 && (
|
||||
<div className="mb-2 flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className="font-medium text-gray-600">
|
||||
Delivery Charge
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Remove Delivery Cost: Are you sure you want to remove the delivery cost from this order?')) {
|
||||
removeDeliveryChargeMutation.mutate({ orderId: order.id })
|
||||
}
|
||||
}}
|
||||
disabled={removeDeliveryChargeMutation.isPending}
|
||||
className="ml-2 rounded-md bg-red-100 px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
<MyText className="text-xs font-bold text-red-600">
|
||||
{removeDeliveryChargeMutation.isPending ? 'Removing...' : 'Remove'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
<MyText className="font-medium text-gray-600">
|
||||
₹{order.deliveryCharge}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-between border-t border-gray-200 pt-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className={`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-900' : 'text-gray-900'}`}>
|
||||
Total Amount
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="ml-2 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
|
||||
<MyText className="text-[8px] font-black uppercase text-amber-700">⚡ FLASH</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MyText className={`text-xl font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-blue-600'}`}>
|
||||
₹{order.totalAmount}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1 Hr Delivery Priority Notice */}
|
||||
{order.isFlashDelivery && (
|
||||
<div className="mb-4 rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-5">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Zap size={24} color="#D97706" />
|
||||
<MyText className="ml-3 text-lg font-bold text-amber-900">
|
||||
1 Hr Delivery Order
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="block text-sm leading-5 text-amber-800">
|
||||
⚡ This is a high-priority flash delivery order that must be delivered within 1 hr of placement.
|
||||
</MyText>
|
||||
<div className="mt-3 rounded-lg border border-amber-300 bg-amber-100 p-3">
|
||||
<MyText className="text-sm font-semibold text-amber-900">
|
||||
Expected Delivery: {dayjs(order.createdAt).add(30, 'minutes').format('MMM DD, YYYY • h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Notes */}
|
||||
{order.adminNotes && (
|
||||
<div className="mb-4 rounded-2xl border border-yellow-100 bg-yellow-50 p-5">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<StickyNote size={18} color="#D97706" />
|
||||
<MyText className="ml-2 text-sm font-bold text-yellow-800">
|
||||
Admin Notes
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm leading-5 text-yellow-900">
|
||||
{order.adminNotes}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Incidents Section */}
|
||||
{order.userId && (
|
||||
<UserIncidentsView
|
||||
userId={order.userId}
|
||||
orderId={order.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Coupon Applied Section */}
|
||||
{order.couponCode && (
|
||||
<div className="mb-4 rounded-2xl border border-emerald-100 bg-emerald-50 p-5 shadow-sm">
|
||||
<MyText className="mb-3 block text-lg font-bold text-emerald-800">
|
||||
Coupon Applied
|
||||
</MyText>
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-100 p-4">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<TicketPercent size={20} color="#10B981" />
|
||||
<MyText className="ml-2 font-bold text-emerald-800">
|
||||
{order.couponCode}
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="block text-sm text-emerald-700">
|
||||
{order.couponDescription}
|
||||
</MyText>
|
||||
<div className="mt-3 flex flex-row items-center justify-between">
|
||||
<MyText className="font-medium text-emerald-600">
|
||||
Discount Applied:
|
||||
</MyText>
|
||||
<MyText className="text-lg font-bold text-emerald-800">
|
||||
-₹{order.discountAmount}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refund Coupon Section */}
|
||||
{order.orderStatus?.refundCouponId && (
|
||||
<div className="mb-4 rounded-2xl border border-blue-100 bg-blue-50 p-5 shadow-sm">
|
||||
<MyText className="mb-3 block text-lg font-bold text-blue-800">
|
||||
Refund Coupon
|
||||
</MyText>
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-100 p-4">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<TicketPercent size={20} color="#2563EB" />
|
||||
<MyText className="ml-2 font-bold text-blue-800">
|
||||
{order.couponCode}
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="block text-sm text-blue-700">
|
||||
Generated refund coupon for order cancellation
|
||||
</MyText>
|
||||
<div className="mt-3 flex flex-row items-center justify-between">
|
||||
<MyText className="font-medium text-blue-600">
|
||||
Value:
|
||||
</MyText>
|
||||
<MyText className="text-lg font-bold text-blue-800">
|
||||
₹{order.couponData?.discountAmount}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TEMPORARILY HIDDEN: Refund Details Section */}
|
||||
{/* WARNING: This section contains functional refund and cancellation management features */}
|
||||
{/* DO NOT REMOVE - This is temporarily commented out for UI simplification */}
|
||||
{/* When ready to re-enable, simply uncomment the entire block below */}
|
||||
</div>
|
||||
<div className="h-32" />
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
<div className="fixed inset-x-0 bottom-0 border-t border-gray-100 bg-white p-4 pb-4">
|
||||
{order.status !== 'cancelled' && (
|
||||
<button
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="mb-3 w-full items-center rounded-xl bg-red-500 py-4 shadow-lg"
|
||||
>
|
||||
<MyText className="text-base font-bold text-white">
|
||||
Cancel Order
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/orders' })}
|
||||
className="w-full items-center rounded-xl bg-gray-900 py-4 shadow-lg"
|
||||
>
|
||||
<MyText className="text-base font-bold text-white">
|
||||
Manage Orders
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generate Coupon Dialog */}
|
||||
<BottomDialog
|
||||
open={generateCouponDialogOpen}
|
||||
onClose={() => setGenerateCouponDialogOpen(false)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="mb-6 items-center">
|
||||
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
|
||||
<TicketPercent size={24} color="#10B981" />
|
||||
</span>
|
||||
<MyText className="block text-center text-xl font-bold text-gray-900">
|
||||
Generate Refund Coupon
|
||||
</MyText>
|
||||
<MyText className="mt-2 block text-center text-sm leading-5 text-gray-500">
|
||||
Create a one-time use coupon for the customer equal to the order amount. Valid for 30 days.
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-row items-start rounded-xl border border-amber-100 bg-amber-50 p-4">
|
||||
<Info size={20} color="#D97706" className="mt-0.5" />
|
||||
<MyText className="ml-2 flex-1 text-sm leading-5 text-amber-800">
|
||||
This only works for online payment orders. COD orders cannot generate refund coupons.
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-3">
|
||||
<button
|
||||
className="flex-1 items-center rounded-xl bg-gray-100 py-3.5"
|
||||
onClick={() => setGenerateCouponDialogOpen(false)}
|
||||
>
|
||||
<MyText className="font-bold text-gray-700">Cancel</MyText>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 items-center rounded-xl bg-emerald-500 py-3.5 shadow-sm disabled:opacity-50"
|
||||
onClick={handleGenerateCoupon}
|
||||
disabled={generateCouponMutation.isPending}
|
||||
>
|
||||
<MyText className="font-bold text-white">
|
||||
{generateCouponMutation.isPending
|
||||
? 'Generating...'
|
||||
: 'Generate Coupon'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Initiate Refund Dialog */}
|
||||
<BottomDialog
|
||||
open={initiateRefundDialogOpen}
|
||||
onClose={() => setInitiateRefundDialogOpen(false)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="mb-6 items-center">
|
||||
<span className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Banknote size={24} color="#EF4444" />
|
||||
</span>
|
||||
<MyText className="block text-center text-xl font-bold text-gray-900">
|
||||
Initiate Refund
|
||||
</MyText>
|
||||
<MyText className="mt-2 block text-center text-sm text-gray-500">
|
||||
Process a refund directly to the customer's source account via Razorpay.
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{/* Refund Type Selection */}
|
||||
<div className="mb-6">
|
||||
<MyText className="mb-3 block text-sm font-bold uppercase tracking-wide text-gray-900">
|
||||
Refund Type
|
||||
</MyText>
|
||||
<div className="flex flex-row gap-3">
|
||||
<button
|
||||
className={`flex-1 items-center rounded-xl border-2 p-4 ${refundType === 'percent'
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-100 bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setRefundType('percent')}
|
||||
>
|
||||
<Percent
|
||||
size={24}
|
||||
color={refundType === 'percent' ? '#3B82F6' : '#9CA3AF'}
|
||||
className="mb-2"
|
||||
/>
|
||||
<MyText
|
||||
className={`text-sm font-bold ${refundType === 'percent' ? 'text-blue-700' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
Percentage
|
||||
</MyText>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`flex-1 items-center rounded-xl border-2 p-4 ${refundType === 'amount'
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-100 bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setRefundType('amount')}
|
||||
>
|
||||
<Banknote
|
||||
size={24}
|
||||
color={refundType === 'amount' ? '#3B82F6' : '#9CA3AF'}
|
||||
className="mb-2"
|
||||
/>
|
||||
<MyText
|
||||
className={`text-sm font-bold ${refundType === 'amount' ? 'text-blue-700' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
Fixed Amount
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refund Value Input */}
|
||||
<div className="mb-6">
|
||||
<MyInput
|
||||
topLabel={`Refund ${refundType === 'percent' ? 'Percentage (%)' : 'Amount (₹)'
|
||||
}`}
|
||||
value={refundValue}
|
||||
onChange={(e) => setRefundValue(e.target.value)}
|
||||
type="number"
|
||||
placeholder={refundType === 'percent' ? '100' : '0.00'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-row items-start rounded-xl border border-amber-100 bg-amber-50 p-4">
|
||||
<Info size={20} color="#D97706" className="mt-0.5" />
|
||||
<MyText className="ml-2 flex-1 text-sm leading-5 text-amber-800">
|
||||
For COD orders, refunds are processed immediately upon delivery confirmation.
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-3">
|
||||
<button
|
||||
className="flex-1 items-center rounded-xl bg-gray-100 py-3.5"
|
||||
onClick={() => setInitiateRefundDialogOpen(false)}
|
||||
>
|
||||
<MyText className="font-bold text-gray-700">Cancel</MyText>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 items-center rounded-xl bg-red-500 py-3.5 shadow-sm disabled:opacity-50"
|
||||
onClick={handleInitiateRefund}
|
||||
disabled={initiateRefundMutation.isPending}
|
||||
>
|
||||
<MyText className="font-bold text-white">
|
||||
{initiateRefundMutation.isPending
|
||||
? 'Processing...'
|
||||
: 'Confirm Refund'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
{/* Cancel Order Dialog */}
|
||||
<CancelOrderDialog
|
||||
orderId={order.id}
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
818
apps/admin-web/src/routes/dashboard.orders.list.tsx
Normal file
818
apps/admin-web/src/routes/dashboard.orders.list.tsx
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
p as MyText,
|
||||
pInput as MyInput,
|
||||
MyButton,
|
||||
AppContainer,
|
||||
BottomDialog,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
SearchBar,
|
||||
MyFlatList
|
||||
} from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Zap,
|
||||
Clock,
|
||||
TriangleAlert,
|
||||
MapPin,
|
||||
CalendarDays,
|
||||
ShoppingCart,
|
||||
StickyNote,
|
||||
ShieldCheck,
|
||||
EllipsisVertical,
|
||||
ListFilter,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import CancelOrderDialog from '../components/CancelOrderDialog'
|
||||
import { OrderOptionsMenu } from '../components/OrderOptionsMenu'
|
||||
import type { AdminOrderListItem, AdminOrderListItemProduct } from '@packages/shared'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/orders/list')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
filter: (s.filter as string) ?? '',
|
||||
slotId: (s.slotId as string) ?? ''
|
||||
}),
|
||||
component: Orders,
|
||||
})
|
||||
|
||||
const AdminNotesForm = ({ orderId, existingNotes, onClose, refetch }: { orderId: string, existingNotes?: string | null, onClose: () => void, refetch: () => void }) => {
|
||||
const [notesText, setNotesText] = useState(existingNotes || '')
|
||||
const updateNotesMutation = trpc.admin.order.updateNotes.useMutation()
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<MyText className="mb-4 text-lg font-bold">Admin Notes</MyText>
|
||||
<MyInput
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
value={notesText}
|
||||
onChange={(e) => setNotesText(e.target.value)}
|
||||
placeholder="Enter admin notes..."
|
||||
className="min-h-24"
|
||||
/>
|
||||
<div className="mt-4 flex flex-row">
|
||||
<MyButton
|
||||
variant="blue"
|
||||
className="ml-2 flex-1"
|
||||
disabled={updateNotesMutation.isPending}
|
||||
onClick={() => {
|
||||
updateNotesMutation.mutate(
|
||||
{ orderId: parseInt(orderId), adminNotes: notesText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
window.alert('Success: Notes updated successfully')
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to update notes')
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
>
|
||||
<MyText className="text-center font-semibold text-white">Save</MyText>
|
||||
</MyButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Order list row — derived from shared AdminOrderListItem (serialized dates,
|
||||
// coupon display fields, optional flags)
|
||||
type OrderItemRow = Omit<
|
||||
AdminOrderListItemProduct,
|
||||
'id' | 'skuName' | 'features' | 'isPackaged' | 'isPackageVerified'
|
||||
> & {
|
||||
id?: number,
|
||||
isPackaged?: boolean,
|
||||
isPackageVerified?: boolean
|
||||
}
|
||||
|
||||
type OrderType = Omit<
|
||||
AdminOrderListItem,
|
||||
'customerName' | 'customerMobile' | 'items' | 'createdAt' | 'adminNotes' | 'userNotes' | 'userNegativityScore'
|
||||
> & {
|
||||
customerName: string | null,
|
||||
customerMobile?: string | null,
|
||||
items: OrderItemRow[],
|
||||
createdAt: string,
|
||||
adminNotes?: string | null,
|
||||
userNotes?: string | null,
|
||||
userNegativityScore?: number,
|
||||
couponCode?: string,
|
||||
couponDescription?: string,
|
||||
discountAmount?: number
|
||||
}
|
||||
|
||||
const OrderItem = ({ order, refetch }: { order: OrderType, refetch: () => void }) => {
|
||||
const navigate = useNavigate()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [itemsDialogOpen, setItemsDialogOpen] = useState(false)
|
||||
const [notesDialogOpen, setNotesDialogOpen] = useState(false)
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
|
||||
const [userNotesDialogOpen, setUserNotesDialogOpen] = useState(false)
|
||||
const [adminNotesDialogOpen, setAdminNotesDialogOpen] = useState(false)
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<number>>(new Set())
|
||||
|
||||
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation()
|
||||
const updateDeliveredMutation = trpc.admin.order.updateDelivered.useMutation()
|
||||
const updateItemPackagingMutation = trpc.admin.order.updateOrderItemPackaging.useMutation()
|
||||
|
||||
const handleOrderPress = () => {
|
||||
navigate({ to: '/dashboard/orders/$id', params: { id: String(order.orderId) } })
|
||||
}
|
||||
|
||||
const handleMenuOption = () => {
|
||||
setMenuOpen(false)
|
||||
navigate({ to: '/dashboard/orders/$id', params: { id: String(order.orderId) } })
|
||||
}
|
||||
|
||||
const handleMarkPackaged = (isPackaged: boolean) => {
|
||||
updatePackagedMutation.mutate(
|
||||
{ orderId: order.orderId.toString(), isPackaged },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleMarkDelivered = (isDelivered: boolean) => {
|
||||
updateDeliveredMutation.mutate(
|
||||
{ orderId: order.orderId.toString(), isDelivered },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleItemPackagingToggle = (itemId: number, field: 'isPackaged' | 'isPackageVerified', value: boolean) => {
|
||||
setUpdatingItems((prev) => new Set(prev).add(itemId))
|
||||
|
||||
updateItemPackagingMutation.mutate(
|
||||
{ orderItemId: itemId, [field]: value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUpdatingItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(itemId)
|
||||
return newSet
|
||||
})
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setUpdatingItems((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(itemId)
|
||||
return newSet
|
||||
})
|
||||
ErrorToast(error.message || 'Failed to update packaging status')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={handleOrderPress}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleOrderPress() }}
|
||||
className="mx-4 mb-4 flex w-[calc(100%-2rem)] cursor-pointer flex-col overflow-hidden rounded-xl border border-gray-100 bg-white text-left shadow-sm"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<div className="border-b border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<MyText className={`mr-2 text-lg font-bold ${order.status === 'cancelled' ? 'text-red-600' : (order.userNegativityScore && order.userNegativityScore > 0 ? 'text-yellow-600' : 'text-gray-900')}`}>
|
||||
{order.customerName || order.customerMobile || 'Unknown Customer'}
|
||||
</MyText>
|
||||
<span className="mr-2 rounded bg-gray-200 px-2 py-0.5">
|
||||
<MyText className="text-xs font-medium text-gray-600">#{order.readableId}</MyText>
|
||||
</span>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="flex flex-row items-center rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
|
||||
<Zap size={12} color="#D97706" />
|
||||
<MyText className="ml-1 text-xs font-bold text-amber-700">FLASH</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<Clock size={12} color="#6B7280" />
|
||||
<MyText className="ml-1 text-xs text-gray-500">
|
||||
{dayjs(order.createdAt).format('MMM D, h:mm A')}
|
||||
</MyText>
|
||||
{order.userNegativityScore && order.userNegativityScore > 0 && (
|
||||
<span className="ml-2 flex flex-row items-center">
|
||||
<TriangleAlert size={14} color="#CA8A04" />
|
||||
<MyText className="ml-1 text-xs font-semibold text-yellow-600">Negative Customer</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), setMenuOpen(true) }}
|
||||
className="-mr-2 -mt-2 rounded-full p-2"
|
||||
aria-label="Order options"
|
||||
>
|
||||
<EllipsisVertical size={16} color="#9CA3AF" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="p-4">
|
||||
{/* Status Badges */}
|
||||
<div className="mb-4 flex flex-row flex-wrap gap-4">
|
||||
<span className="flex flex-row items-center gap-1">
|
||||
<MyText className="text-sm font-semibold text-gray-600">Packaged</MyText>
|
||||
<Checkbox
|
||||
checked={order.isPackaged}
|
||||
onPress={() => {}}
|
||||
size={18}
|
||||
fillColor="#6B7280"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="flex flex-row items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MyText className="text-xs font-semibold text-gray-600">Delivered</MyText>
|
||||
<Checkbox
|
||||
checked={order.isDelivered}
|
||||
onPress={() => handleMarkDelivered(!order.isDelivered)}
|
||||
size={18}
|
||||
fillColor="#10B981"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
</span>
|
||||
{order.status === 'cancelled' && (
|
||||
<span className="rounded-full border border-red-500 px-2 py-0.5">
|
||||
<MyText className="text-xs font-bold text-red-600">CANCELLED</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delivery Info */}
|
||||
<div className="mb-4 flex flex-row items-start rounded-lg bg-blue-50/50 p-3">
|
||||
<MapPin size={18} color="#3B82F6" className="mt-0.5" />
|
||||
<div className="ml-2 flex-1">
|
||||
<MyText className="mb-0.5 text-xs font-bold uppercase tracking-wide text-blue-800">Delivery Address</MyText>
|
||||
<MyText className="text-sm leading-5 text-gray-700" numberOfLines={2}>
|
||||
{order.address}
|
||||
</MyText>
|
||||
<div className="mt-2 flex flex-row items-center">
|
||||
<CalendarDays size={14} color="#6B7280" />
|
||||
<MyText className="ml-1 text-xs text-gray-600">
|
||||
{order.isFlashDelivery ? '1 Hr Delivery:' : 'Slot:'} {order.isFlashDelivery ? dayjs(order.createdAt).add(30, 'minutes').format('MMM D, h:mm A') : order.deliveryTime ? dayjs(order.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled'}
|
||||
</MyText>
|
||||
</div>
|
||||
{order.isFlashDelivery && (
|
||||
<div className="mt-1 flex flex-row items-center rounded bg-amber-50 px-2 py-1">
|
||||
<Zap size={12} color="#D97706" />
|
||||
<MyText className="ml-1 text-xs font-medium text-amber-700">
|
||||
1 Hour Delivery • High Priority
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Summary & Total */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), setItemsDialogOpen(true) }}
|
||||
className="mr-3 flex flex-1 flex-row items-center rounded-lg bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<ShoppingCart size={16} color="#3B82F6" />
|
||||
<MyText className="ml-2 text-sm font-medium text-blue-700">
|
||||
{`${order.items.length} ${order.items.length === 1 ? 'item' : 'items'}`}
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5">
|
||||
<Zap size={12} color="#D97706" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="flex flex-row items-center">
|
||||
<MyText className="mr-2 text-base font-semibold text-gray-900">Total:</MyText>
|
||||
<MyText className={`text-lg font-bold ${order.isFlashDelivery ? 'text-amber-700' : 'text-gray-900'}`}>{`₹${order.totalAmount}`}</MyText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coupons */}
|
||||
{order.couponCode && (
|
||||
<div className="mb-4">
|
||||
<MyText className="mb-2 text-xs font-bold uppercase tracking-wide text-gray-500">Applied Coupons</MyText>
|
||||
<div className="rounded-lg border border-pink-200 bg-pink-50 p-3">
|
||||
<MyText className="mb-1 text-sm font-medium text-pink-800">
|
||||
{order.couponCode}
|
||||
</MyText>
|
||||
{order.couponDescription && (
|
||||
<MyText className="mb-2 text-xs text-pink-600">
|
||||
{order.couponDescription}
|
||||
</MyText>
|
||||
)}
|
||||
{order.discountAmount && (
|
||||
<MyText className="text-sm font-bold text-pink-800">
|
||||
{`Discount: ₹${order.discountAmount}`}
|
||||
</MyText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="flex flex-row gap-2">
|
||||
{order.userNotes && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), setUserNotesDialogOpen(true) }}
|
||||
className="flex flex-1 flex-row items-center rounded-lg bg-amber-50 p-3"
|
||||
>
|
||||
<StickyNote size={18} color="#D97706" />
|
||||
<MyText className="ml-2 font-medium text-amber-800">
|
||||
User Notes
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
{order.adminNotes && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), setAdminNotesDialogOpen(true) }}
|
||||
className="flex flex-1 flex-row items-center rounded-lg bg-blue-50 p-3"
|
||||
>
|
||||
<ShieldCheck size={18} color="#2563EB" />
|
||||
<MyText className="ml-2 font-medium text-blue-800">
|
||||
Admin Notes
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer / Delivery Charge */}
|
||||
{order.deliveryCharge > 0 && (
|
||||
<div className="border-t border-gray-100 pt-3">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<MyText className="text-sm text-gray-500">Delivery Charge</MyText>
|
||||
<MyText className="text-sm text-gray-900">{`₹${order.deliveryCharge}`}</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrderOptionsMenu
|
||||
open={menuOpen}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
order={{
|
||||
id: order.id,
|
||||
readableId: order.readableId,
|
||||
isPackaged: order.isPackaged,
|
||||
isDelivered: order.isDelivered,
|
||||
isFlashDelivery: order.isFlashDelivery,
|
||||
address: order.address,
|
||||
addressId: order.addressId,
|
||||
adminNotes: order.adminNotes,
|
||||
userNotes: order.userNotes,
|
||||
latitude: order.latitude,
|
||||
longitude: order.longitude,
|
||||
status: order.status,
|
||||
}}
|
||||
onViewDetails={handleMenuOption}
|
||||
onTogglePackaged={() => handleMarkPackaged(!order.isPackaged)}
|
||||
onToggleDelivered={() => handleMarkDelivered(!order.isDelivered)}
|
||||
onOpenAdminNotes={() => {
|
||||
setMenuOpen(false)
|
||||
setNotesDialogOpen(true)
|
||||
}}
|
||||
onCancelOrder={() => {
|
||||
setMenuOpen(false)
|
||||
setCancelDialogOpen(true)
|
||||
}}
|
||||
onAttachLocation={() => refetch()}
|
||||
onWhatsApp={() => {}}
|
||||
onDial={() => {}}
|
||||
/>
|
||||
|
||||
<BottomDialog open={itemsDialogOpen} onClose={() => setItemsDialogOpen(false)}>
|
||||
<div className="py-6">
|
||||
<div className="mb-4 flex flex-row items-center justify-between">
|
||||
<MyText className="text-lg font-bold text-gray-800">
|
||||
Order Items
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="flex flex-row items-center rounded-full border border-amber-200 bg-amber-100 px-2 py-1">
|
||||
<Zap size={14} color="#D97706" />
|
||||
<MyText className="ml-1 text-xs font-bold text-amber-700">FLASH</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MyText className="mb-6 text-sm text-gray-600">
|
||||
{`Total: ₹${order.totalAmount}`}
|
||||
</MyText>
|
||||
{order.items.map((item, idx) => (
|
||||
<div key={idx} className="border-b border-gray-50 py-2 last:border-0">
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="mr-2 items-center justify-center rounded bg-gray-100 px-2 py-1">
|
||||
<MyText className="text-xs font-bold text-gray-600">{`${item.quantity} x ${item.unit}`}</MyText>
|
||||
</span>
|
||||
<MyText className="flex-1 text-sm text-gray-800" numberOfLines={1}>
|
||||
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}
|
||||
</MyText>
|
||||
{item.isPackaged !== undefined && item.isPackageVerified !== undefined && (
|
||||
<>
|
||||
<span className="mr-3 flex flex-row items-center gap-1">
|
||||
<MyText className="text-sm font-medium text-gray-600">pkg</MyText>
|
||||
<Checkbox
|
||||
checked={item.isPackaged}
|
||||
onPress={() => handleItemPackagingToggle(item.id!, 'isPackaged', !item.isPackaged)}
|
||||
size={18}
|
||||
fillColor={updatingItems.has(item.id!) ? '#F59E0B' : '#10B981'}
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
</span>
|
||||
<span className="flex flex-row items-center gap-1">
|
||||
<MyText className="text-sm font-medium text-gray-600">verf</MyText>
|
||||
<Checkbox
|
||||
checked={item.isPackageVerified}
|
||||
onPress={() => handleItemPackagingToggle(item.id!, 'isPackageVerified', !item.isPackageVerified)}
|
||||
size={18}
|
||||
fillColor={updatingItems.has(item.id!) ? '#F59E0B' : '#10B981'}
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
</span>
|
||||
{updatingItems.has(item.id!) && (
|
||||
<Loader2 size={14} color="#F59E0B" className="ml-1 animate-spin" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
|
||||
<AdminNotesForm orderId={order.orderId} existingNotes={order.adminNotes} onClose={() => setNotesDialogOpen(false)} refetch={refetch} />
|
||||
</BottomDialog>
|
||||
|
||||
<CancelOrderDialog
|
||||
orderId={order.id}
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
|
||||
<BottomDialog open={userNotesDialogOpen} onClose={() => setUserNotesDialogOpen(false)}>
|
||||
<div className="p-6">
|
||||
<MyText className="mb-4 text-lg font-bold text-gray-800">
|
||||
User Notes
|
||||
</MyText>
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<MyText className="text-sm leading-5 text-amber-900">
|
||||
{order.userNotes}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
<BottomDialog open={adminNotesDialogOpen} onClose={() => setAdminNotesDialogOpen(false)}>
|
||||
<div className="p-6">
|
||||
<MyText className="mb-4 text-lg font-bold text-gray-800">
|
||||
Admin Notes
|
||||
</MyText>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<MyText className="text-sm leading-5 text-blue-900">
|
||||
{order.adminNotes}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Orders() {
|
||||
const { filter, slotId } = Route.useSearch()
|
||||
const [selectedSlot, setSelectedSlot] = useState<number | null>(null)
|
||||
const [selectedSlotType, setSelectedSlotType] = useState<'slot' | 'flash' | null>(null)
|
||||
const [packagedFilter, setPackagedFilter] = useState<'all' | 'packaged' | 'not_packaged'>('all')
|
||||
const [packagedChecked, setPackagedChecked] = useState(false)
|
||||
const [notPackagedChecked, setNotPackagedChecked] = useState(false)
|
||||
const [deliveredFilter, setDeliveredFilter] = useState<'all' | 'delivered' | 'not_delivered'>('all')
|
||||
const [deliveredChecked, setDeliveredChecked] = useState(false)
|
||||
const [notDeliveredChecked, setNotDeliveredChecked] = useState(false)
|
||||
const [cancellationFilter, setCancellationFilter] = useState<'all' | 'cancelled' | 'not_cancelled'>('all')
|
||||
const [cancelledChecked, setCancelledChecked] = useState(false)
|
||||
const [notCancelledChecked, setNotCancelledChecked] = useState(false)
|
||||
const [flashDeliveryFilter, setFlashDeliveryFilter] = useState<'all' | 'flash' | 'regular'>('all')
|
||||
const [flashChecked, setFlashChecked] = useState(false)
|
||||
const [regularChecked, setRegularChecked] = useState(false)
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false)
|
||||
|
||||
// Handle initial filter from URL params
|
||||
useEffect(() => {
|
||||
if (filter === 'flash' || slotId === 'flash') {
|
||||
setSelectedSlotType('flash')
|
||||
setFlashDeliveryFilter('flash')
|
||||
setFlashChecked(true)
|
||||
setRegularChecked(false)
|
||||
} else if (slotId && !Number.isNaN(Number(slotId))) {
|
||||
setSelectedSlotType('slot')
|
||||
setSelectedSlot(Number(slotId))
|
||||
}
|
||||
}, [filter, slotId])
|
||||
const { data: slotsData } = trpc.admin.slots.getAll.useQuery()
|
||||
const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, refetch } = trpc.admin.order.getAll.useInfiniteQuery(
|
||||
{
|
||||
limit: 20,
|
||||
slotId: selectedSlotType === 'slot' ? selectedSlot : null,
|
||||
packagedFilter,
|
||||
deliveredFilter,
|
||||
cancellationFilter,
|
||||
flashDeliveryFilter: selectedSlotType === 'flash' ? 'flash' : flashDeliveryFilter
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage?.nextCursor,
|
||||
}
|
||||
)
|
||||
|
||||
const orders = data?.pages.flatMap((page) => page?.orders) || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-600">Loading orders...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const slotOptions = [
|
||||
{ label: '⚡ Flash Deliveries', value: 'flash' },
|
||||
...(slotsData?.slots?.map((slot) => ({
|
||||
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
||||
value: slot.id.toString(),
|
||||
})) || [])
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<MyFlatList
|
||||
data={orders}
|
||||
keyExtractor={(item, index) => String(item?.orderId ?? index)}
|
||||
renderItem={(item) => item ? <OrderItem order={item as unknown as OrderType} refetch={refetch} /> : null}
|
||||
onRefresh={() => refetch()}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-between bg-white p-4">
|
||||
<div className="mr-4 flex-1">
|
||||
<Dropdown
|
||||
label="Select Slot"
|
||||
options={slotOptions}
|
||||
value={selectedSlotType === 'flash' ? 'flash' : (selectedSlot?.toString() || '')}
|
||||
onValueChange={(val) => {
|
||||
if (val === 'flash') {
|
||||
setSelectedSlotType('flash')
|
||||
setSelectedSlot(null)
|
||||
setFlashDeliveryFilter('flash')
|
||||
// Reset other filters when switching to flash
|
||||
setPackagedFilter('all')
|
||||
setPackagedChecked(false)
|
||||
setNotPackagedChecked(false)
|
||||
setDeliveredFilter('all')
|
||||
setDeliveredChecked(false)
|
||||
setNotDeliveredChecked(false)
|
||||
setCancellationFilter('all')
|
||||
setCancelledChecked(false)
|
||||
setNotCancelledChecked(false)
|
||||
} else {
|
||||
setSelectedSlotType('slot')
|
||||
setSelectedSlot(val ? Number(val) : null)
|
||||
setFlashDeliveryFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFilterDialogOpen(true)}
|
||||
className="p-2"
|
||||
aria-label="Open filters"
|
||||
>
|
||||
<ListFilter size={24} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
{!isLoading && selectedSlotType && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||
<MyText className="text-center text-gray-600">
|
||||
{selectedSlotType === 'flash'
|
||||
? `${orders.length} Flash delivery orders`
|
||||
: `${orders.length} Orders in slot`
|
||||
}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="ml-2 text-gray-600">Loading more...</MyText>
|
||||
</div>
|
||||
) : hasNextPage ? (
|
||||
<MyButton variant="blue" onClick={() => fetchNextPage()}>
|
||||
Load more
|
||||
</MyButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<BottomDialog open={filterDialogOpen} onClose={() => setFilterDialogOpen(false)}>
|
||||
<AppContainer>
|
||||
<div className="mt-4">
|
||||
<MyText className="mb-2 text-lg font-semibold">Packaged Status</MyText>
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={packagedChecked}
|
||||
onPress={() => {
|
||||
const newValue = !packagedChecked
|
||||
setPackagedChecked(newValue)
|
||||
if (newValue && notPackagedChecked) {
|
||||
setPackagedFilter('all')
|
||||
} else if (newValue) {
|
||||
setPackagedFilter('packaged')
|
||||
} else if (notPackagedChecked) {
|
||||
setPackagedFilter('not_packaged')
|
||||
} else {
|
||||
setPackagedFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Packaged</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={notPackagedChecked}
|
||||
onPress={() => {
|
||||
const newValue = !notPackagedChecked
|
||||
setNotPackagedChecked(newValue)
|
||||
if (packagedChecked && newValue) {
|
||||
setPackagedFilter('all')
|
||||
} else if (newValue) {
|
||||
setPackagedFilter('not_packaged')
|
||||
} else if (packagedChecked) {
|
||||
setPackagedFilter('packaged')
|
||||
} else {
|
||||
setPackagedFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Not Packaged</MyText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<MyText className="mb-2 text-lg font-semibold">Delivered Status</MyText>
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={deliveredChecked}
|
||||
onPress={() => {
|
||||
const newValue = !deliveredChecked
|
||||
setDeliveredChecked(newValue)
|
||||
if (newValue && notDeliveredChecked) {
|
||||
setDeliveredFilter('all')
|
||||
} else if (newValue) {
|
||||
setDeliveredFilter('delivered')
|
||||
} else if (notDeliveredChecked) {
|
||||
setDeliveredFilter('not_delivered')
|
||||
} else {
|
||||
setDeliveredFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Delivered</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={notDeliveredChecked}
|
||||
onPress={() => {
|
||||
const newValue = !notDeliveredChecked
|
||||
setNotDeliveredChecked(newValue)
|
||||
if (deliveredChecked && newValue) {
|
||||
setDeliveredFilter('all')
|
||||
} else if (newValue) {
|
||||
setDeliveredFilter('not_delivered')
|
||||
} else if (deliveredChecked) {
|
||||
setDeliveredFilter('delivered')
|
||||
} else {
|
||||
setDeliveredFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Not Delivered</MyText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<MyText className="mb-2 text-lg font-semibold">Cancellation Status</MyText>
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={cancelledChecked}
|
||||
onPress={() => {
|
||||
const newValue = !cancelledChecked
|
||||
setCancelledChecked(newValue)
|
||||
if (newValue && notCancelledChecked) {
|
||||
setCancellationFilter('all')
|
||||
} else if (newValue) {
|
||||
setCancellationFilter('cancelled')
|
||||
} else if (notCancelledChecked) {
|
||||
setCancellationFilter('not_cancelled')
|
||||
} else {
|
||||
setCancellationFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Cancelled</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={notCancelledChecked}
|
||||
onPress={() => {
|
||||
const newValue = !notCancelledChecked
|
||||
setNotCancelledChecked(newValue)
|
||||
if (cancelledChecked && newValue) {
|
||||
setCancellationFilter('all')
|
||||
} else if (newValue) {
|
||||
setCancellationFilter('not_cancelled')
|
||||
} else if (cancelledChecked) {
|
||||
setCancellationFilter('cancelled')
|
||||
} else {
|
||||
setCancellationFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Not Cancelled</MyText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<MyText className="mb-2 text-lg font-semibold">Delivery Type</MyText>
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={flashChecked}
|
||||
onPress={() => {
|
||||
const newValue = !flashChecked
|
||||
setFlashChecked(newValue)
|
||||
if (newValue && regularChecked) {
|
||||
setFlashDeliveryFilter('all')
|
||||
} else if (newValue) {
|
||||
setFlashDeliveryFilter('flash')
|
||||
} else if (regularChecked) {
|
||||
setFlashDeliveryFilter('regular')
|
||||
} else {
|
||||
setFlashDeliveryFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">⚡ 1 Hr Delivery</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
checked={regularChecked}
|
||||
onPress={() => {
|
||||
const newValue = !regularChecked
|
||||
setRegularChecked(newValue)
|
||||
if (flashChecked && newValue) {
|
||||
setFlashDeliveryFilter('all')
|
||||
} else if (newValue) {
|
||||
setFlashDeliveryFilter('regular')
|
||||
} else if (flashChecked) {
|
||||
setFlashDeliveryFilter('flash')
|
||||
} else {
|
||||
setFlashDeliveryFilter('all')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MyText className="ml-2">Regular Delivery</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
</BottomDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
798
apps/admin-web/src/routes/dashboard.orders.sequence.tsx
Normal file
798
apps/admin-web/src/routes/dashboard.orders.sequence.tsx
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
p as MyText,
|
||||
BottomDialog,
|
||||
Checkbox,
|
||||
Dropdown
|
||||
} from 'web-components'
|
||||
import dayjs from 'dayjs'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import {
|
||||
MapPin,
|
||||
Map as MapIcon,
|
||||
EllipsisVertical,
|
||||
GripVertical,
|
||||
Save,
|
||||
ChevronLeft,
|
||||
CalendarX,
|
||||
ClipboardList
|
||||
} from 'lucide-react'
|
||||
import { OrderOptionsMenu } from '../components/OrderOptionsMenu'
|
||||
import CancelOrderDialog from '../components/CancelOrderDialog'
|
||||
import { OrderNotesForm } from '../components/OrderNotesForm'
|
||||
import { SearchableSelect } from '@/components/SearchableSelect'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/orders/sequence')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
slotId: (s.slotId as string) ?? ''
|
||||
}),
|
||||
component: DeliverySequences,
|
||||
})
|
||||
|
||||
// Define types outside
|
||||
interface OrderWithSequence {
|
||||
sequenceId: number,
|
||||
readableId: number,
|
||||
isPackaged: boolean,
|
||||
totalAmount: number,
|
||||
address: string,
|
||||
latitude: number | null,
|
||||
longitude: number | null,
|
||||
id: number,
|
||||
isDelivered: boolean,
|
||||
addressId: number,
|
||||
adminNotes?: string | null,
|
||||
customerName?: string | null,
|
||||
assignedUserName?: string | null
|
||||
}
|
||||
|
||||
interface SlotProgressProps {
|
||||
slotId: number,
|
||||
orders: any[]
|
||||
}
|
||||
|
||||
const SlotProgress: React.FC<SlotProgressProps> = ({ slotId, orders }) => {
|
||||
const delivered = orders.filter((o) => o.isDelivered).length
|
||||
const undelivered = orders.filter((o) => !o.isDelivered).length
|
||||
const packaged = orders.filter((o) => o.isPackaged).length
|
||||
const notPackaged = orders.filter((o) => !o.isPackaged).length
|
||||
|
||||
const stats = [
|
||||
{ label: 'Delivered', value: delivered },
|
||||
{ label: 'Undelivered', value: undelivered },
|
||||
{ label: 'Packaged', value: packaged },
|
||||
{ label: 'Not Packaged', value: notPackaged },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mb-2 border-b border-gray-200 bg-white px-4 py-3">
|
||||
<MyText className="mb-3 text-sm text-gray-700">
|
||||
Slot {slotId} Statistics
|
||||
</MyText>
|
||||
<div className="flex flex-row flex-wrap">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="w-1/2 p-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<MyText className="text-lg font-bold text-gray-900">
|
||||
{stat.value}
|
||||
</MyText>
|
||||
<MyText className="text-xs text-gray-500">{stat.label}</MyText>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface OrderItemProps {
|
||||
item: OrderWithSequence,
|
||||
isPending: boolean,
|
||||
setSelectedOrder: (order: OrderWithSequence | null) => void,
|
||||
setShowOrderMenu: (show: boolean) => void,
|
||||
selectedOrderIds: number[],
|
||||
onToggleOrder: (id: number) => void,
|
||||
assignedUserName?: string | null,
|
||||
staffData?: { staff?: Array<{ id: number, name: string }> },
|
||||
setSelectedUserId?: (id: number) => void
|
||||
}
|
||||
|
||||
const OrderItem: React.FC<OrderItemProps> = ({
|
||||
item,
|
||||
isPending,
|
||||
setSelectedOrder,
|
||||
setShowOrderMenu,
|
||||
selectedOrderIds,
|
||||
onToggleOrder,
|
||||
assignedUserName,
|
||||
staffData,
|
||||
setSelectedUserId,
|
||||
}) => {
|
||||
const orderItem = item
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: orderItem.sequenceId, disabled: isPending })
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
touchAction: 'none'
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mx-4 my-2"
|
||||
>
|
||||
<div
|
||||
className={`rounded-xl border bg-white p-4 ${isDragging ? 'border-blue-500 shadow-xl' : 'border-gray-100 shadow-sm'}`}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
{/* Checkbox and Drag Handle */}
|
||||
<div className="mr-4 flex-col items-center">
|
||||
<Checkbox
|
||||
checked={selectedOrderIds.includes(orderItem.id)}
|
||||
onPress={() => onToggleOrder(orderItem.id)}
|
||||
size={20}
|
||||
fillColor="#3b82f6"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<GripVertical
|
||||
size={24}
|
||||
color={isDragging ? '#3b82f6' : '#9ca3af'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex flex-row items-start justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<MyText className="text-lg font-bold text-gray-800">
|
||||
#{orderItem.readableId}
|
||||
</MyText>
|
||||
<span className="flex flex-row items-center gap-1">
|
||||
<MyText className="text-xs font-medium text-gray-600">
|
||||
Pkg
|
||||
</MyText>
|
||||
<Checkbox
|
||||
checked={orderItem.isPackaged}
|
||||
onPress={() => {}}
|
||||
size={16}
|
||||
fillColor="#6B7280"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="mr-2 rounded-lg border border-green-100 bg-green-50 px-2 py-1">
|
||||
<MyText className="text-sm font-bold text-green-700">
|
||||
₹{orderItem.totalAmount}
|
||||
</MyText>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedOrder(orderItem)
|
||||
setShowOrderMenu(true)
|
||||
}}
|
||||
className="p-2"
|
||||
aria-label="Order options"
|
||||
>
|
||||
<EllipsisVertical size={20} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start">
|
||||
<MapPin size={14} color="#6b7280" className="mr-1 mt-0.5" />
|
||||
<MyText className="flex-1 text-xs text-gray-500" numberOfLines={2}>
|
||||
{orderItem.address}
|
||||
</MyText>
|
||||
{orderItem.latitude && orderItem.longitude && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`https://www.google.com/maps?q=${orderItem.latitude},${orderItem.longitude}`,
|
||||
'_blank'
|
||||
)
|
||||
}}
|
||||
className="ml-2"
|
||||
aria-label="Open in maps"
|
||||
>
|
||||
<MapIcon size={16} color="#3b82f6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{assignedUserName && (
|
||||
<div className="mt-2 flex flex-row items-center">
|
||||
<MyText className="text-xs text-gray-500">
|
||||
Assigned to{' '}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => {
|
||||
const assignedUserId = staffData?.staff?.find(
|
||||
(s: { id: number, name: string }) => s.name === assignedUserName
|
||||
)?.id
|
||||
if (assignedUserId && setSelectedUserId) {
|
||||
setSelectedUserId(assignedUserId)
|
||||
}
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
<MyText className="text-xs text-blue-600 underline">
|
||||
{assignedUserName}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Admin Notes */}
|
||||
{orderItem.adminNotes && (
|
||||
<div className="mt-2 rounded-lg border border-yellow-100 bg-yellow-50 p-2">
|
||||
<MyText className="text-xs leading-4 text-yellow-900">
|
||||
{orderItem.adminNotes}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeliverySequences() {
|
||||
const { slotId } = Route.useSearch()
|
||||
const selectedSlotId = slotId ? Number(slotId) : null
|
||||
const [localOrderedOrders, setLocalOrderedOrders] = useState<
|
||||
OrderWithSequence[]
|
||||
>([])
|
||||
const [showOrderMenu, setShowOrderMenu] = useState(false)
|
||||
const [selectedOrder, setSelectedOrder] = useState<OrderWithSequence | null>(
|
||||
null
|
||||
)
|
||||
const [selectedUserId, setSelectedUserId] = useState<number>(-1)
|
||||
const [selectedOrderIds, setSelectedOrderIds] = useState<number[]>([])
|
||||
const [hasSequenceChanged, setHasSequenceChanged] = useState(false)
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false)
|
||||
const [notesDialogOpen, setNotesDialogOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
)
|
||||
|
||||
const { data: slotsData, refetch: refetchSlots } =
|
||||
trpc.admin.slots.getAll.useQuery()
|
||||
const {
|
||||
data: ordersData,
|
||||
isLoading: ordersLoading,
|
||||
refetch: refetchOrders,
|
||||
} = trpc.admin.order.getSlotOrders.useQuery(
|
||||
{ slotId: String(selectedSlotId) },
|
||||
{
|
||||
enabled: !!selectedSlotId,
|
||||
}
|
||||
)
|
||||
const { data: sequenceData, refetch: refetchSequence } =
|
||||
trpc.admin.slots.getDeliverySequence.useQuery(
|
||||
{ id: String(selectedSlotId) },
|
||||
{
|
||||
enabled: !!selectedSlotId,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: staffData } = trpc.admin.staffUser.getStaff.useQuery()
|
||||
|
||||
// Auto-select first slot if no slotId provided
|
||||
useEffect(() => {
|
||||
if (!slotId && slotsData?.slots && slotsData.slots.length > 0) {
|
||||
navigate({ to: '/dashboard/orders/sequence', search: { slotId: String(slotsData.slots[0].id) }, replace: true })
|
||||
}
|
||||
}, [slotId, slotsData, navigate])
|
||||
|
||||
const updateSequenceMutation =
|
||||
trpc.admin.slots.updateDeliverySequence.useMutation()
|
||||
const updatePackagedMutation = trpc.admin.order.updatePackaged.useMutation()
|
||||
const updateDeliveredMutation =
|
||||
trpc.admin.order.updateDelivered.useMutation()
|
||||
|
||||
// Manual refresh functionality
|
||||
useManualRefresh(() => {
|
||||
refetchSlots()
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
})
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetchSlots()
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
})
|
||||
|
||||
const slots = slotsData?.slots || []
|
||||
const orders = ordersData?.data || []
|
||||
const deliverySequence = sequenceData?.deliverySequence || []
|
||||
|
||||
const slotOptions =
|
||||
slots?.map((slot) => ({
|
||||
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
||||
value: slot.id,
|
||||
})) || []
|
||||
|
||||
const userOptions = [
|
||||
{ label: 'Unassigned', value: -1 },
|
||||
{ label: 'All Users', value: -2 },
|
||||
...(staffData?.staff?.map((staff) => ({
|
||||
label: staff.name,
|
||||
value: staff.id,
|
||||
})) || []),
|
||||
]
|
||||
|
||||
// Create ordered orders based on delivery sequence
|
||||
const computedOrderedOrders = useMemo(() => {
|
||||
if (orders.length > 0) {
|
||||
const userSequence =
|
||||
selectedUserId !== -1
|
||||
? (deliverySequence as any)?.[String(selectedUserId)] || []
|
||||
: null
|
||||
if (selectedUserId !== -1 && userSequence && userSequence.length > 0) {
|
||||
// Sort orders according to user's sequence
|
||||
const sequenceMap = new Map(
|
||||
userSequence.map((id: number, index: number) => [id, index])
|
||||
)
|
||||
const ordered = orders
|
||||
.filter((order) => userSequence.includes(order.id))
|
||||
.map((order) => ({ ...order, sequenceId: order.id }))
|
||||
.sort((a, b) => {
|
||||
const aIndex = sequenceMap.get(a.sequenceId) ?? Infinity
|
||||
const bIndex = sequenceMap.get(b.sequenceId) ?? Infinity
|
||||
return aIndex < bIndex ? -1 : aIndex > bIndex ? 1 : 0
|
||||
})
|
||||
return ordered
|
||||
} else if (selectedUserId === -1) {
|
||||
// Show unassigned orders (not in any user's sequence)
|
||||
const assignedIds = new Set(
|
||||
Object.values(deliverySequence as any).flat()
|
||||
)
|
||||
const ordered = orders
|
||||
.filter((order) => !assignedIds.has(order.id))
|
||||
.map((order) => ({ ...order, sequenceId: order.id }))
|
||||
.sort((a, b) =>
|
||||
a.sequenceId < b.sequenceId
|
||||
? -1
|
||||
: a.sequenceId > b.sequenceId
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
return ordered
|
||||
} else if (selectedUserId === -2) {
|
||||
// Show all orders with assignment info
|
||||
const orderToUserMap = new Map<number, number>()
|
||||
Object.entries(deliverySequence as any).forEach(([userId, orderIds]) => {
|
||||
(orderIds as number[]).forEach((orderId) => {
|
||||
orderToUserMap.set(orderId, parseInt(userId))
|
||||
})
|
||||
})
|
||||
const ordered = orders
|
||||
.map((order) => {
|
||||
const assignedUserId = orderToUserMap.get(order.id)
|
||||
const assignedUser = staffData?.staff?.find(
|
||||
(s) => s.id === assignedUserId
|
||||
)
|
||||
return {
|
||||
...order,
|
||||
sequenceId: order.id,
|
||||
assignedUserName: assignedUser?.name || null,
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.sequenceId < b.sequenceId
|
||||
? -1
|
||||
: a.sequenceId > b.sequenceId
|
||||
? 1
|
||||
: 0
|
||||
)
|
||||
return ordered
|
||||
} else {
|
||||
// No sequence for user, show empty
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}, [ordersData, sequenceData, selectedUserId])
|
||||
|
||||
// Sync local state with computed orders
|
||||
useEffect(() => {
|
||||
setLocalOrderedOrders(computedOrderedOrders as OrderWithSequence[])
|
||||
}, [computedOrderedOrders])
|
||||
|
||||
const handleDragEnd = ({ data }: { data: OrderWithSequence[] }) => {
|
||||
if (selectedUserId !== -1 && selectedUserId !== -2) {
|
||||
setLocalOrderedOrders(data)
|
||||
setHasSequenceChanged(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDndDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = localOrderedOrders.findIndex((o) => o.sequenceId === active.id)
|
||||
const newIndex = localOrderedOrders.findIndex((o) => o.sequenceId === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
handleDragEnd({ data: arrayMove(localOrderedOrders, oldIndex, newIndex) })
|
||||
}
|
||||
|
||||
const handleSaveSequence = () => {
|
||||
if (!selectedSlotId || selectedUserId === -1) return
|
||||
|
||||
const newSequence = { ...(deliverySequence as any) }
|
||||
newSequence[String(selectedUserId)] = localOrderedOrders.map(
|
||||
(order) => order.sequenceId
|
||||
)
|
||||
|
||||
updateSequenceMutation.mutate(
|
||||
{
|
||||
id: selectedSlotId,
|
||||
deliverySequence: newSequence,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasSequenceChanged(false)
|
||||
window.alert('Success: Delivery sequence updated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(
|
||||
`Failed to update delivery sequence: ${
|
||||
error.message || 'Unknown error'
|
||||
}`
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!slotsData) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 pt-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading slots...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (slotsData.slots.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pt-6">
|
||||
<CalendarX size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No slots available
|
||||
</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-gray-50 pt-6">
|
||||
{selectedSlotId ? (
|
||||
<>
|
||||
{/* Header Section */}
|
||||
<div className="z-10 flex flex-row items-center border-b border-gray-200 bg-white px-4 py-4 shadow-sm">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="p-2"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<div className="mx-2 flex-[2]">
|
||||
<Dropdown
|
||||
label="Select Slot"
|
||||
options={slotOptions}
|
||||
value={selectedSlotId || ''}
|
||||
onValueChange={(val) => {
|
||||
if (val !== '' && val !== null && val !== undefined) {
|
||||
navigate({ to: '/dashboard/orders/sequence', search: { slotId: String(Number(val)) }, replace: true })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-2 flex-1">
|
||||
<Dropdown
|
||||
label="Select User"
|
||||
options={userOptions}
|
||||
value={selectedUserId}
|
||||
onValueChange={(val) => setSelectedUserId(Number(val))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
{ordersLoading ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading orders...</MyText>
|
||||
</div>
|
||||
) : localOrderedOrders.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<ClipboardList size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No orders found for this slot
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col pb-8">
|
||||
<SlotProgress slotId={selectedSlotId!} orders={orders} />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDndDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={localOrderedOrders.map((o) => o.sequenceId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{localOrderedOrders.map((item) => (
|
||||
<OrderItem
|
||||
key={item.sequenceId}
|
||||
item={item}
|
||||
isPending={updateSequenceMutation.isPending}
|
||||
setSelectedOrder={setSelectedOrder}
|
||||
setShowOrderMenu={setShowOrderMenu}
|
||||
selectedOrderIds={selectedOrderIds}
|
||||
onToggleOrder={(id) => {
|
||||
setSelectedOrderIds((prev) =>
|
||||
prev.includes(id)
|
||||
? prev.filter((i) => i !== id)
|
||||
: [...prev, id]
|
||||
)
|
||||
}}
|
||||
assignedUserName={selectedUserId === -2 ? item.assignedUserName : undefined}
|
||||
staffData={staffData}
|
||||
setSelectedUserId={setSelectedUserId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment panel (replaces FAB-triggered dropdown; SearchableSelect renders its own trigger) */}
|
||||
{selectedOrderIds.length > 0 && (
|
||||
<div className="absolute bottom-4 right-4 w-56 rounded-xl bg-white p-3 shadow-lg">
|
||||
<SearchableSelect
|
||||
label="Assign To"
|
||||
options={[
|
||||
...userOptions.filter((opt) => opt.value !== -1),
|
||||
{ label: 'None', value: -3 },
|
||||
]}
|
||||
value={-1}
|
||||
onValueChange={(val) => {
|
||||
const numVal = Number(val)
|
||||
if (numVal !== -1) {
|
||||
if (numVal === -3) {
|
||||
// Unassign selected orders
|
||||
const newSequence = { ...(deliverySequence as any) }
|
||||
Object.keys(newSequence).forEach((userId) => {
|
||||
newSequence[userId] = newSequence[userId].filter(
|
||||
(id: number) => !selectedOrderIds.includes(id)
|
||||
)
|
||||
})
|
||||
// Save
|
||||
updateSequenceMutation.mutate(
|
||||
{
|
||||
id: selectedSlotId,
|
||||
deliverySequence: newSequence,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedOrderIds([])
|
||||
refetchSequence()
|
||||
window.alert(
|
||||
'Success: Orders unassigned successfully'
|
||||
)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(
|
||||
`Failed to unassign orders: ${
|
||||
error.message || 'Unknown error'
|
||||
}`
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Assign selected orders to the user
|
||||
// Update deliverySequence map
|
||||
const newSequence = { ...(deliverySequence as any) }
|
||||
// Remove from all other users
|
||||
Object.keys(newSequence).forEach((userId) => {
|
||||
newSequence[userId] = newSequence[userId].filter(
|
||||
(id: number) => !selectedOrderIds.includes(id)
|
||||
)
|
||||
})
|
||||
// Add to selected user
|
||||
if (!newSequence[String(numVal)])
|
||||
newSequence[String(numVal)] = []
|
||||
newSequence[String(numVal)] = [
|
||||
...new Set([
|
||||
...newSequence[String(numVal)],
|
||||
...selectedOrderIds,
|
||||
]),
|
||||
]
|
||||
// Save
|
||||
updateSequenceMutation.mutate(
|
||||
{
|
||||
id: selectedSlotId,
|
||||
deliverySequence: newSequence,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedOrderIds([])
|
||||
refetchSequence()
|
||||
window.alert(
|
||||
'Success: Orders assigned successfully'
|
||||
)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(
|
||||
`Failed to assign orders: ${
|
||||
error.message || 'Unknown error'
|
||||
}`
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAB for Save */}
|
||||
{hasSequenceChanged && (
|
||||
<div className="fixed bottom-4 left-4">
|
||||
<button
|
||||
onClick={handleSaveSequence}
|
||||
className="rounded-full bg-blue-600 p-4 shadow-lg disabled:opacity-50"
|
||||
disabled={updateSequenceMutation.isPending}
|
||||
aria-label="Save sequence"
|
||||
>
|
||||
<Save size={24} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<CalendarX size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No slot selected. Please select a slot to view delivery sequence.
|
||||
</MyText>
|
||||
<button
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<MyText className="font-bold text-white">Go Back</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Menu Dialog */}
|
||||
<OrderOptionsMenu
|
||||
open={showOrderMenu}
|
||||
onClose={() => setShowOrderMenu(false)}
|
||||
order={{
|
||||
id: selectedOrder?.id || 0,
|
||||
readableId: selectedOrder?.readableId || 0,
|
||||
isPackaged: selectedOrder?.isPackaged || false,
|
||||
isDelivered: selectedOrder?.isDelivered || false,
|
||||
address: selectedOrder?.address || '',
|
||||
addressId: selectedOrder?.addressId || 0,
|
||||
adminNotes: selectedOrder?.adminNotes || null,
|
||||
latitude: selectedOrder?.latitude || null,
|
||||
longitude: selectedOrder?.longitude || null,
|
||||
}}
|
||||
onViewDetails={() => {
|
||||
if (selectedOrder) {
|
||||
navigate({ to: '/dashboard/orders/$id', params: { id: String(selectedOrder.id) } })
|
||||
}
|
||||
setShowOrderMenu(false)
|
||||
}}
|
||||
onTogglePackaged={() => {
|
||||
if (!selectedOrder) return
|
||||
updatePackagedMutation.mutate(
|
||||
{ orderId: selectedOrder.id.toString(), isPackaged: !selectedOrder.isPackaged },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
},
|
||||
onError: () => {
|
||||
ErrorToast('Failed to update packaged status')
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
onToggleDelivered={() => {
|
||||
if (!selectedOrder) return
|
||||
updateDeliveredMutation.mutate(
|
||||
{ orderId: selectedOrder.id.toString(), isDelivered: !selectedOrder.isDelivered },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
},
|
||||
onError: () => {
|
||||
ErrorToast('Failed to update delivered status')
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
onOpenAdminNotes={() => {
|
||||
setShowOrderMenu(false)
|
||||
setNotesDialogOpen(true)
|
||||
}}
|
||||
onCancelOrder={() => {
|
||||
setShowOrderMenu(false)
|
||||
setCancelDialogOpen(true)
|
||||
}}
|
||||
onAttachLocation={() => {}}
|
||||
onWhatsApp={() => {}}
|
||||
onDial={() => {}}
|
||||
/>
|
||||
|
||||
<CancelOrderDialog
|
||||
orderId={selectedOrder?.id || 0}
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
}}
|
||||
/>
|
||||
|
||||
<BottomDialog open={notesDialogOpen} onClose={() => setNotesDialogOpen(false)}>
|
||||
<OrderNotesForm
|
||||
orderId={selectedOrder?.id || 0}
|
||||
initialNotes={selectedOrder?.adminNotes || ''}
|
||||
onSuccess={() => {
|
||||
refetchOrders()
|
||||
refetchSequence()
|
||||
setNotesDialogOpen(false)
|
||||
}}
|
||||
onCancel={() => setNotesDialogOpen(false)}
|
||||
/>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
apps/admin-web/src/routes/dashboard.orders.tsx
Normal file
99
apps/admin-web/src/routes/dashboard.orders.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { ListOrdered, Route as RouteIcon } from 'lucide-react'
|
||||
import { p as MyText, Dropdown } from 'web-components'
|
||||
import dayjs from 'dayjs'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useNavigationTarget } from '@/lib/navigation-target'
|
||||
import { useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/orders')({
|
||||
component: ManageOrders,
|
||||
})
|
||||
|
||||
function ManageOrders() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { getNavigationTarget } = useNavigationTarget()
|
||||
const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null)
|
||||
const { data: slotsData, refetch } = trpc.admin.slots.getAll.useQuery()
|
||||
|
||||
useEffect(() => {
|
||||
const target = getNavigationTarget()
|
||||
if (target) {
|
||||
navigate({ to: target } as any)
|
||||
}
|
||||
}, [getNavigationTarget, navigate])
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
if (location.pathname !== '/dashboard/orders') {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
// Create slot options with Flash Deliveries as first option
|
||||
const slotOptions = [
|
||||
{ label: '⚡ Flash Deliveries', value: 'flash' },
|
||||
...(slotsData?.slots?.map((slot) => ({
|
||||
label: dayjs(slot.deliveryTime).format('ddd DD MMM, h:mm a'),
|
||||
value: slot.id.toString()
|
||||
})) || [])
|
||||
]
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Delivery Sequences',
|
||||
icon: <RouteIcon size={32} color="white" />,
|
||||
color: 'bg-purple-500',
|
||||
onClick: () => {
|
||||
if (selectedSlotId === 'flash') {
|
||||
window.alert('Flash Deliveries: Flash deliveries do not have delivery sequences. Use the Orders menu to manage flash deliveries.')
|
||||
return
|
||||
}
|
||||
navigate({ to: '/dashboard/orders/sequence', search: { slotId: selectedSlotId ?? '' } })
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Orders',
|
||||
icon: <ListOrdered size={32} color="white" />,
|
||||
color: 'bg-cyan-500',
|
||||
onClick: () => {
|
||||
if (selectedSlotId === 'flash') {
|
||||
navigate({ to: '/dashboard/orders/list', search: { filter: 'flash', slotId: '' } })
|
||||
} else {
|
||||
navigate({ to: '/dashboard/orders/list', search: { filter: '', slotId: selectedSlotId ?? '' } })
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-white p-6">
|
||||
<div className="mb-6">
|
||||
<Dropdown
|
||||
label="Select Slot"
|
||||
options={slotOptions}
|
||||
value={selectedSlotId || ''}
|
||||
onValueChange={(val) => setSelectedSlotId(String(val))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={item.onClick}
|
||||
className={`flex-1 rounded-2xl p-6 text-center shadow-lg ${item.color}`}
|
||||
>
|
||||
<span className="flex flex-col items-center">
|
||||
<span className="mb-2">{item.icon}</span>
|
||||
<MyText className="text-center text-lg font-bold text-white">{item.title}</MyText>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
420
apps/admin-web/src/routes/dashboard.prices.tsx
Normal file
420
apps/admin-web/src/routes/dashboard.prices.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Save, Pencil, EllipsisVertical, Shuffle } from 'lucide-react'
|
||||
import {
|
||||
AppContainer,
|
||||
p as MyText,
|
||||
pInput as MyTextInput,
|
||||
BottomDialog,
|
||||
Checkbox,
|
||||
MyFlatList,
|
||||
} from 'web-components'
|
||||
import MultiSelect from '@/components/MultiSelect'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/prices')({
|
||||
component: PricesOverview,
|
||||
})
|
||||
|
||||
interface SkuItemProps {
|
||||
sku: any
|
||||
productName: string
|
||||
hasChanges: (skuId: number) => boolean
|
||||
pendingChanges: Record<string, any>
|
||||
setPendingChanges: React.Dispatch<React.SetStateAction<Record<string, any>>>
|
||||
openEditDialog: (sku: any, productName: string) => void
|
||||
}
|
||||
|
||||
const SkuItemComponent: React.FC<SkuItemProps> = ({
|
||||
sku,
|
||||
productName,
|
||||
hasChanges,
|
||||
pendingChanges,
|
||||
setPendingChanges,
|
||||
openEditDialog,
|
||||
}) => {
|
||||
const changed = hasChanges(sku.id)
|
||||
const change = pendingChanges[sku.id] || {}
|
||||
const displayPrice = change.price !== undefined ? change.price : sku.price
|
||||
const displayMarketPrice = change.marketPrice !== undefined ? change.marketPrice : sku.marketPrice
|
||||
const displayFlashPrice = change.flashPrice !== undefined ? change.flashPrice : sku.flashPrice
|
||||
const displayUnit = (sku.features || []).map((f: any) => f.featureValue).join(' ') || '—'
|
||||
|
||||
return (
|
||||
<div className="relative mb-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="absolute right-2 top-2">
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-full ${changed ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
{changed && (
|
||||
<svg width={10} height={10} viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<img
|
||||
src={sku.images?.[0] || 'https://via.placeholder.com/32x32?text=No+Image'}
|
||||
className="mr-3 h-10 w-10 rounded-lg object-cover"
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-row items-center">
|
||||
<div className="flex-1">
|
||||
<MyText className="text-base font-medium text-gray-800" numberOfLines={1}>
|
||||
{productName.length > 20 ? productName.substring(0, 20) + '...' : productName}
|
||||
</MyText>
|
||||
<MyText className="text-xs text-gray-500" numberOfLines={1}>
|
||||
{sku.displayName || sku.name || ''}
|
||||
</MyText>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-row items-center">
|
||||
<span className="mr-1">
|
||||
<Checkbox
|
||||
checked={change.isFlashAvailable ?? sku.isFlashAvailable ?? false}
|
||||
onPress={() => {
|
||||
const currentValue = change.isFlashAvailable ?? sku.isFlashAvailable ?? false
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
[sku.id]: {
|
||||
...prev[sku.id],
|
||||
isFlashAvailable: !currentValue,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<MyText className="text-sm text-gray-600">Flash</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-col items-center">
|
||||
<MyText className="mb-1 text-xs text-gray-500">Our Price</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className="text-sm font-bold text-green-600">₹{displayPrice}</MyText>
|
||||
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
|
||||
<Pencil size={14} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<MyText className="mb-1 text-xs text-gray-500">Market Price</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className="text-sm text-gray-600">{displayMarketPrice ? `₹${displayMarketPrice}` : 'N/A'}</MyText>
|
||||
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
|
||||
<Pencil size={14} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<MyText className="mb-1 text-xs text-gray-500">Flash Price</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className="text-sm text-orange-600">{displayFlashPrice ? `₹${displayFlashPrice}` : 'N/A'}</MyText>
|
||||
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
|
||||
<Pencil size={14} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<MyText className="mb-1 text-xs text-gray-500">Size</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<MyText className="text-sm text-blue-600">{displayUnit}</MyText>
|
||||
<button onClick={() => openEditDialog(sku, productName)} className="ml-1">
|
||||
<Pencil size={14} color="#6b7280" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PendingChange {
|
||||
price?: number
|
||||
marketPrice?: number | null
|
||||
flashPrice?: number | null
|
||||
isFlashAvailable?: boolean
|
||||
}
|
||||
|
||||
interface EditDialogState {
|
||||
open: boolean
|
||||
sku: any
|
||||
productName: string
|
||||
tempPrice: string
|
||||
tempMarketPrice: string
|
||||
tempFlashPrice: string
|
||||
}
|
||||
|
||||
function PricesOverview() {
|
||||
const navigate = useNavigate()
|
||||
const [selectedStores, setSelectedStores] = useState<string[]>([])
|
||||
const [pendingChanges, setPendingChanges] = useState<Record<number, PendingChange>>({})
|
||||
const [editDialog, setEditDialog] = useState<EditDialogState>({
|
||||
open: false,
|
||||
sku: null,
|
||||
productName: '',
|
||||
tempPrice: '',
|
||||
tempMarketPrice: '',
|
||||
tempFlashPrice: '',
|
||||
})
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const { data: productsData, isLoading: productsLoading, refetch: refetchProducts } =
|
||||
trpc.admin.product.getProducts.useQuery()
|
||||
const { data: storesData, isLoading: storesLoading } =
|
||||
trpc.admin.store.getStores.useQuery()
|
||||
|
||||
const updatePricesMutation = trpc.admin.product.updateProductPrices.useMutation()
|
||||
|
||||
const stores = storesData?.stores || []
|
||||
const allProducts = productsData?.products || []
|
||||
|
||||
const sortedStores = useMemo(() =>
|
||||
[...stores].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[stores]
|
||||
)
|
||||
|
||||
const storeOptions = useMemo(() =>
|
||||
sortedStores.map((store) => ({
|
||||
label: store.name,
|
||||
value: store.id.toString(),
|
||||
})),
|
||||
[sortedStores]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (stores.length > 0 && selectedStores.length === 0) {
|
||||
setSelectedStores(stores.map((s) => s.id.toString()))
|
||||
}
|
||||
}, [stores, selectedStores])
|
||||
|
||||
const allSkus = useMemo(() => {
|
||||
const skus: any[] = []
|
||||
for (const product of allProducts) {
|
||||
if (product.skus && product.skus.length > 0) {
|
||||
for (const sku of product.skus) {
|
||||
skus.push({ ...sku, _productName: product.name, _storeId: product.storeId })
|
||||
}
|
||||
}
|
||||
}
|
||||
return skus
|
||||
}, [allProducts])
|
||||
|
||||
const filteredSkus = useMemo(() => {
|
||||
if (selectedStores.length === 0) return allSkus
|
||||
return allSkus.filter((sku) =>
|
||||
sku._storeId && selectedStores.includes(sku._storeId.toString())
|
||||
)
|
||||
}, [allSkus, selectedStores])
|
||||
|
||||
const hasChanges = (skuId: number) => !!pendingChanges[skuId]
|
||||
|
||||
const openEditDialog = (sku: any, productName: string) => {
|
||||
const change = pendingChanges[sku.id] || {}
|
||||
setEditDialog({
|
||||
open: true,
|
||||
sku,
|
||||
productName,
|
||||
tempPrice: (change.price ?? sku.price)?.toString() || '',
|
||||
tempMarketPrice: (change.marketPrice ?? sku.marketPrice)?.toString() || '',
|
||||
tempFlashPrice: (change.flashPrice ?? sku.flashPrice)?.toString() || '',
|
||||
})
|
||||
}
|
||||
|
||||
const saveEditDialog = () => {
|
||||
const price = parseFloat(editDialog.tempPrice)
|
||||
const marketPrice = editDialog.tempMarketPrice ? parseFloat(editDialog.tempMarketPrice) : null
|
||||
const flashPrice = editDialog.tempFlashPrice ? parseFloat(editDialog.tempFlashPrice) : null
|
||||
|
||||
if (isNaN(price) || price <= 0) {
|
||||
window.alert('Please enter a valid price')
|
||||
return
|
||||
}
|
||||
|
||||
if (editDialog.tempMarketPrice && (isNaN(marketPrice!) || marketPrice! <= 0)) {
|
||||
window.alert('Please enter a valid market price')
|
||||
return
|
||||
}
|
||||
|
||||
if (editDialog.tempFlashPrice && (isNaN(flashPrice!) || flashPrice! <= 0)) {
|
||||
window.alert('Please enter a valid flash price')
|
||||
return
|
||||
}
|
||||
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
[editDialog.sku.id]: {
|
||||
price: price !== parseFloat(editDialog.sku.price) ? price : undefined,
|
||||
marketPrice: marketPrice !== parseFloat(editDialog.sku.marketPrice || '0') ? marketPrice : undefined,
|
||||
flashPrice: flashPrice !== parseFloat(editDialog.sku.flashPrice || '0') ? flashPrice : undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const updates = Object.entries(pendingChanges).map(([skuId, change]) => {
|
||||
const sku = allSkus.find((s) => s.id === parseInt(skuId))
|
||||
const update: any = { productId: sku?.id }
|
||||
if (change.price !== undefined) update.price = change.price
|
||||
if (change.marketPrice !== undefined) update.marketPrice = change.marketPrice
|
||||
if (change.flashPrice !== undefined) update.flashPrice = change.flashPrice
|
||||
if (change.isFlashAvailable !== undefined) update.isFlashAvailable = change.isFlashAvailable
|
||||
return update
|
||||
})
|
||||
|
||||
updatePricesMutation.mutate(
|
||||
{ updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setPendingChanges({})
|
||||
refetchProducts()
|
||||
window.alert('Prices updated successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
window.alert(`Failed to update prices: ${error.message || 'Unknown error'}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const changeCount = Object.keys(pendingChanges).length
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center border-b border-gray-200 bg-white px-4 py-4">
|
||||
<div className="mr-4 flex-1">
|
||||
<MultiSelect
|
||||
label="Filter by Stores"
|
||||
options={storeOptions}
|
||||
value={selectedStores}
|
||||
onValueChange={(value) => setSelectedStores(value as string[])}
|
||||
placeholder="Select stores"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`mr-3 flex flex-row items-center rounded-lg px-4 py-2 ${
|
||||
changeCount > 0 && !updatePricesMutation.isPending ? 'bg-blue-600' : 'bg-gray-300'
|
||||
}`}
|
||||
onClick={handleSave}
|
||||
disabled={changeCount === 0 || updatePricesMutation.isPending}
|
||||
>
|
||||
{updatePricesMutation.isPending ? (
|
||||
<span className={`mr-2 h-4 w-4 animate-spin rounded-full border-2 ${changeCount > 0 ? 'border-white/40 border-t-white' : 'border-gray-400/40 border-t-gray-500'}`} />
|
||||
) : (
|
||||
<Save
|
||||
size={20}
|
||||
color={changeCount > 0 ? 'white' : '#6b7280'}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
<MyText
|
||||
className={`font-medium ${changeCount > 0 ? 'text-white' : 'text-gray-500'}`}
|
||||
>
|
||||
Save ({changeCount})
|
||||
</MyText>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMenu(true)}
|
||||
className="-mr-2 p-2"
|
||||
>
|
||||
<EllipsisVertical size={16} color="#9CA3AF" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{productsLoading || storesLoading ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading...</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<MyFlatList
|
||||
data={filteredSkus}
|
||||
renderItem={(item) => (
|
||||
<SkuItemComponent
|
||||
sku={item}
|
||||
productName={item._productName}
|
||||
hasChanges={hasChanges}
|
||||
pendingChanges={pendingChanges}
|
||||
setPendingChanges={setPendingChanges}
|
||||
openEditDialog={openEditDialog}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
className="p-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: '', tempMarketPrice: '', tempPrice: '' })}>
|
||||
<div className="p-4">
|
||||
<MyText className="mb-1 text-lg font-bold">{editDialog.productName}</MyText>
|
||||
<MyText className="mb-4 text-sm text-gray-500">{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText>
|
||||
|
||||
<div className="mb-4">
|
||||
<MyText className="mb-1 text-sm font-medium">Our Price</MyText>
|
||||
<MyTextInput
|
||||
value={editDialog.tempPrice}
|
||||
onChange={(e) => setEditDialog({ ...editDialog, tempPrice: e.target.value })}
|
||||
type="number"
|
||||
placeholder="Enter price"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<MyText className="mb-1 text-sm font-medium">Market Price (Optional)</MyText>
|
||||
<MyTextInput
|
||||
value={editDialog.tempMarketPrice}
|
||||
onChange={(e) => setEditDialog({ ...editDialog, tempMarketPrice: e.target.value })}
|
||||
type="number"
|
||||
placeholder="Enter market price"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<MyText className="mb-1 text-sm font-medium">Flash Price (Optional)</MyText>
|
||||
<MyTextInput
|
||||
value={editDialog.tempFlashPrice}
|
||||
onChange={(e) => setEditDialog({ ...editDialog, tempFlashPrice: e.target.value })}
|
||||
type="number"
|
||||
placeholder="Enter flash price"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full items-center rounded-md bg-blue-600 py-3"
|
||||
onClick={saveEditDialog}
|
||||
>
|
||||
<MyText className="font-medium text-white">Update Price</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
|
||||
<BottomDialog open={showMenu} onClose={() => setShowMenu(false)}>
|
||||
<div className="p-6">
|
||||
<MyText className="mb-6 text-lg font-bold text-gray-800">
|
||||
Options
|
||||
</MyText>
|
||||
<button
|
||||
className="flex w-full flex-row items-center rounded-lg bg-gray-50 p-4"
|
||||
onClick={() => {
|
||||
navigate({ to: '/dashboard/rebalance' })
|
||||
setShowMenu(false)
|
||||
}}
|
||||
>
|
||||
<Shuffle size={20} color="#6B7280" />
|
||||
<MyText className="ml-3 font-medium text-gray-800">
|
||||
Re-Balance Orders
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import ProductGroupForm from '../components/ProductGroupForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-groupings/$id/edit')({
|
||||
component: EditProductGroup,
|
||||
})
|
||||
|
||||
function EditProductGroup() {
|
||||
const { id } = Route.useParams()
|
||||
|
||||
const groupId = parseInt(id as string)
|
||||
|
||||
const { data: groupsData, isLoading } = trpc.admin.product.getGroups.useQuery()
|
||||
const groups = groupsData?.groups || []
|
||||
const group = groups.find((g) => g.id === groupId)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText>Loading group...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText>Group not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductGroupForm
|
||||
group={group}
|
||||
onClose={() => window.history.back()}
|
||||
onSuccess={() => window.history.back()}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer } from 'web-components'
|
||||
import ProductGroupForm from '../components/ProductGroupForm'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-groupings/new')({
|
||||
component: CreateProductGroup,
|
||||
})
|
||||
|
||||
function CreateProductGroup() {
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductGroupForm
|
||||
onClose={() => window.history.back()}
|
||||
onSuccess={() => window.history.back()}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
198
apps/admin-web/src/routes/dashboard.product-groupings.tsx
Normal file
198
apps/admin-web/src/routes/dashboard.product-groupings.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Users, Pencil, Trash2, Package, Clock, Group as GroupIcon, Plus } from 'lucide-react'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { theme } from '@/lib/theme'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ProductGroup } from '../components/ProductGroupForm'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-groupings')({
|
||||
component: ProductGroupings,
|
||||
})
|
||||
|
||||
const GroupItem = ({
|
||||
group,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewProducts,
|
||||
index,
|
||||
}: {
|
||||
group: ProductGroup
|
||||
onEdit: (group: ProductGroup) => void
|
||||
onDelete: (id: number) => void
|
||||
onViewProducts: (products: any[]) => void
|
||||
index: number
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-6">
|
||||
<div className="mb-6 flex flex-row items-start justify-between">
|
||||
<div className="flex flex-1 flex-row items-center">
|
||||
<div
|
||||
className="mr-4 flex h-12 w-12 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: theme.colors.brand50 }}
|
||||
>
|
||||
<Users size={24} color={theme.colors.brand600} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MyText className="text-[10px] font-black uppercase tracking-widest text-slate-400">
|
||||
Group Name
|
||||
</MyText>
|
||||
<MyText className="text-xl font-black text-slate-900" numberOfLines={1}>
|
||||
{group.groupName}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<button
|
||||
onClick={() => onEdit(group)}
|
||||
className="mr-2 p-2"
|
||||
>
|
||||
<Pencil size={20} color="#64748B" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(group.id)}
|
||||
className="p-2"
|
||||
>
|
||||
<Trash2 size={20} color="#EF4444" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{group.description && (
|
||||
<div className="mb-6 rounded-2xl border border-slate-100 bg-slate-50 p-4">
|
||||
<MyText className="font-medium text-slate-700">
|
||||
{group.description}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<button
|
||||
onClick={() => onViewProducts(group.products)}
|
||||
className="mr-4 flex flex-row items-center"
|
||||
>
|
||||
<Package size={14} color={theme.colors.brand500} />
|
||||
<MyText className="ml-1.5 text-xs font-bold text-blue-600">
|
||||
{group.productCount} Products
|
||||
</MyText>
|
||||
</button>
|
||||
<div className="flex flex-row items-center">
|
||||
<Clock size={14} color="#94A3B8" />
|
||||
<MyText className="ml-1.5 text-xs font-bold text-slate-500">
|
||||
{dayjs(group.createdAt).format('MMM DD, YYYY')}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductGroupings() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
data: groupsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.product.getGroups.useQuery()
|
||||
const deleteGroup = trpc.admin.product.deleteGroup.useMutation()
|
||||
|
||||
const groups = groupsData?.groups || []
|
||||
const [viewProducts, setViewProducts] = useState<any[] | null>(null)
|
||||
|
||||
useManualRefresh(refetch)
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate({ to: '/dashboard/product-groupings/new' })
|
||||
}
|
||||
|
||||
const handleEdit = (group: ProductGroup) => {
|
||||
navigate({ to: '/dashboard/product-groupings/$id/edit', params: { id: String(group.id) } })
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this group?')) {
|
||||
deleteGroup.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-600">Loading product groups...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-red-600">Error loading groups</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-white px-4">
|
||||
<div className="flex-1 overflow-y-auto pb-32">
|
||||
{groups.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-8">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50">
|
||||
<GroupIcon size={48} color="#94A3B8" />
|
||||
</div>
|
||||
<MyText className="text-xl font-black tracking-tight text-slate-900">
|
||||
No Groups Yet
|
||||
</MyText>
|
||||
<MyText className="mt-2 px-8 text-center font-medium text-slate-500">
|
||||
Start by creating your first product group using the button below.
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group, index) => (
|
||||
<React.Fragment key={group.id}>
|
||||
<GroupItem
|
||||
group={group as ProductGroup}
|
||||
index={index}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onViewProducts={setViewProducts}
|
||||
/>
|
||||
{index < groups.length - 1 && (
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="fixed z-[100] flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
|
||||
style={{ bottom: 32, right: 24, background: 'linear-gradient(135deg, #1570EF, #194185)' }}
|
||||
aria-label="Create group"
|
||||
>
|
||||
<Plus size={32} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
apps/admin-web/src/routes/dashboard.product-tags.edit.tsx
Normal file
118
apps/admin-web/src/routes/dashboard.product-tags.edit.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText, type ImageUploaderNeoItem } from 'web-components'
|
||||
import TagForm, { type TagFormData as BaseTagFormData } from '../components/TagForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-tags/edit')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({ tagId: (s.tagId as string) ?? '' }),
|
||||
component: EditTag,
|
||||
})
|
||||
|
||||
type TagFormData = BaseTagFormData & { existingImageUrl?: string }
|
||||
|
||||
function EditTag() {
|
||||
const { tagId } = Route.useSearch()
|
||||
const tagIdNum = tagId ? parseInt(tagId) : null
|
||||
|
||||
const { data: tagData, isLoading: isLoadingTag, error: tagError } = trpc.admin.product.getProductTagById.useQuery(
|
||||
{ id: tagIdNum || 0 },
|
||||
{ enabled: !!tagIdNum }
|
||||
)
|
||||
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
const updateTag = trpc.admin.product.updateProductTag.useMutation()
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery()
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], removedExisting: boolean) => {
|
||||
if (!tagIdNum) return
|
||||
|
||||
try {
|
||||
let imageUrl: string | null | undefined
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
const blob = await response.blob()
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||
contextString: 'tags',
|
||||
})
|
||||
imageUrl = result.keys[0]
|
||||
uploadUrls = result.presignedUrls
|
||||
} else if (removedExisting) {
|
||||
imageUrl = null
|
||||
}
|
||||
|
||||
await updateTag.mutateAsync({
|
||||
id: tagIdNum,
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
await refetchTags()
|
||||
window.alert('Tag updated successfully')
|
||||
window.history.back()
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to update tag'
|
||||
window.alert(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingTag) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<MyText className="text-gray-500">Loading tag...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (tagError || !tagData?.tag) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<MyText className="text-gray-500">Tag not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const tag = tagData.tag
|
||||
const tagProductIds = tag.productIds || (tag.products || []).map((p: any) => p.productId)
|
||||
const orderedProductIds = (tag.sortOrder || []).filter((id: number) => tagProductIds.includes(id))
|
||||
const remainingProductIds = tagProductIds.filter((id: number) => !orderedProductIds.includes(id))
|
||||
const initialValues: TagFormData = {
|
||||
tagName: tag.tagName,
|
||||
tagDescription: tag.tagDescription || '',
|
||||
isDashboardTag: tag.isDashboardTag,
|
||||
relatedStores: Array.isArray(tag.relatedStores) ? tag.relatedStores : [],
|
||||
productIds: [...orderedProductIds, ...remainingProductIds],
|
||||
existingImageUrl: tag.imageUrl || undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<TagForm
|
||||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
existingImageUrl={tag.imageUrl || undefined}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number, name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
77
apps/admin-web/src/routes/dashboard.product-tags.new.tsx
Normal file
77
apps/admin-web/src/routes/dashboard.product-tags.new.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, type ImageUploaderNeoItem } from 'web-components'
|
||||
import TagForm, { type TagFormData } from '../components/TagForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-tags/new')({
|
||||
component: AddTag,
|
||||
})
|
||||
|
||||
function AddTag() {
|
||||
const createTag = trpc.admin.product.createProductTag.useMutation()
|
||||
const { refetch: refetchTags } = trpc.admin.product.getProductTags.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
const { data: storesData } = trpc.admin.store.getStores.useQuery()
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
const handleSubmit = async (values: TagFormData, images: ImageUploaderNeoItem[], _removedExisting: boolean) => {
|
||||
try {
|
||||
let imageUrl: string | null | undefined
|
||||
let uploadUrls: string[] = []
|
||||
|
||||
const newImage = images.find((image) => image.mimeType !== null)
|
||||
if (newImage) {
|
||||
const response = await fetch(newImage.imgUrl)
|
||||
const blob = await response.blob()
|
||||
const result = await upload({
|
||||
images: [{ blob, mimeType: newImage.mimeType || 'image/jpeg' }],
|
||||
contextString: 'tags',
|
||||
})
|
||||
imageUrl = result.keys[0]
|
||||
uploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
await createTag.mutateAsync({
|
||||
tagName: values.tagName,
|
||||
tagDescription: values.tagDescription || undefined,
|
||||
imageUrl,
|
||||
isDashboardTag: values.isDashboardTag,
|
||||
relatedStores: values.relatedStores,
|
||||
productIds: values.productIds,
|
||||
uploadUrls,
|
||||
})
|
||||
|
||||
await refetchTags()
|
||||
window.alert('Tag created successfully')
|
||||
window.history.back()
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || 'Failed to create tag'
|
||||
window.alert(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const initialValues: TagFormData = {
|
||||
tagName: '',
|
||||
tagDescription: '',
|
||||
isDashboardTag: false,
|
||||
relatedStores: [],
|
||||
productIds: [],
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<TagForm
|
||||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createTag.isPending || isUploading}
|
||||
stores={storesData?.stores.map((store: { id: number, name: string }) => ({ id: store.id, name: store.name })) || []}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
286
apps/admin-web/src/routes/dashboard.product-tags.order.tsx
Normal file
286
apps/admin-web/src/routes/dashboard.product-tags.order.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
|
||||
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, Tag as TagIcon, ChevronLeft, CircleAlert } from 'lucide-react'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { DragOrderItemProps } from '@/types/drag-order-item'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-tags/order')({
|
||||
component: TagOrders,
|
||||
})
|
||||
|
||||
interface Tag {
|
||||
id: number
|
||||
tagName: string
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
const itemStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
height: 60,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: '#e5e7eb',
|
||||
padding: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
}
|
||||
|
||||
const activeItemStyle: React.CSSProperties = {
|
||||
...itemStyle,
|
||||
borderColor: '#3b82f6',
|
||||
boxShadow: '0 4px 8px rgba(59,130,246,0.3)',
|
||||
}
|
||||
|
||||
const SortableTagItem: React.FC<DragOrderItemProps<Tag>> = ({ item, isActive }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id })
|
||||
const active = isActive || isDragging
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
...(active ? activeItemStyle : itemStyle),
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.9 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
}}
|
||||
>
|
||||
<div {...attributes} {...listeners} style={{ marginRight: 8, padding: 2, cursor: 'grab', touchAction: 'none' }}>
|
||||
<GripVertical size={24} color={active ? '#3b82f6' : '#9ca3af'} />
|
||||
</div>
|
||||
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
style={{ width: 30, height: 30, borderRadius: 6, marginRight: 10, objectFit: 'cover' }}
|
||||
alt={item.tagName}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 6, backgroundColor: '#f3f4f6', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<TagIcon size={24} color="#9ca3af" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<MyText style={{ fontSize: 13, color: '#111827', fontWeight: 500, flex: 1, marginRight: 4 }} numberOfLines={1}>
|
||||
{item.tagName}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagOrders() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
|
||||
const { data: constants, isLoading: isLoadingConstants, error: constantsError } = trpc.admin.const.getConstants.useQuery()
|
||||
const { data: tagsData, isLoading: isLoadingTags, error: tagsError } = trpc.admin.product.getProductTags.useQuery()
|
||||
const updateConstants = trpc.admin.const.updateConstants.useMutation()
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
|
||||
|
||||
useEffect(() => {
|
||||
if (tagsData?.tags) {
|
||||
const tagsOrderConstant = constants?.find((c) => c.key === 'tagsOrder')
|
||||
|
||||
let orderedIds: number[] = []
|
||||
|
||||
if (tagsOrderConstant) {
|
||||
const value = tagsOrderConstant.value
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
orderedIds = value.map((id: any) => parseInt(id))
|
||||
} else if (typeof value === 'string') {
|
||||
orderedIds = value.split(',').map((id: string) => parseInt(id.trim())).filter((id) => !isNaN(id))
|
||||
}
|
||||
}
|
||||
|
||||
const tagMap = new Map(tagsData.tags.map((t) => [t.id, t]))
|
||||
|
||||
const sortedTags: Tag[] = []
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const tag = tagMap.get(id)
|
||||
if (tag) {
|
||||
sortedTags.push({
|
||||
id: tag.id,
|
||||
tagName: tag.tagName,
|
||||
imageUrl: tag.imageUrl || null,
|
||||
})
|
||||
tagMap.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const tag of tagMap.values()) {
|
||||
sortedTags.push({
|
||||
id: tag.id,
|
||||
tagName: tag.tagName,
|
||||
imageUrl: tag.imageUrl || null,
|
||||
})
|
||||
}
|
||||
|
||||
setTags(sortedTags)
|
||||
}
|
||||
}, [constants, tagsData])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
setTags((prev) => {
|
||||
const oldIndex = prev.findIndex((t) => t.id === active.id)
|
||||
const newIndex = prev.findIndex((t) => t.id === over.id)
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
setHasChanges(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
const tagIds = tags.map((t) => t.id)
|
||||
|
||||
updateConstants.mutate(
|
||||
{
|
||||
constants: [{
|
||||
key: 'tagsOrder',
|
||||
value: tagIds,
|
||||
}],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHasChanges(false)
|
||||
window.alert('Tag order updated successfully!')
|
||||
queryClient.invalidateQueries({ queryKey: ['const.getConstants'] })
|
||||
},
|
||||
onError: (error) => {
|
||||
window.alert('Failed to update tag order. Please try again.')
|
||||
console.error('Update tag order error:', error)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingConstants || isLoadingTags) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button onClick={() => window.history.back()} className="-ml-4 p-2">
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-center text-gray-500">
|
||||
{isLoadingConstants ? 'Loading order...' : 'Loading tags...'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (constantsError || tagsError) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button onClick={() => window.history.back()} className="-ml-4 p-2">
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<CircleAlert size={64} color="#ef4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
{constantsError ? 'Failed to load order' : 'Failed to load tags'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Go Back</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex flex-row items-center justify-between border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
|
||||
<MyText className="text-xl font-bold text-gray-900">Tag Orders</MyText>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateConstants.isPending}
|
||||
className={`rounded-lg px-4 py-2 ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MyText className={`font-semibold ${
|
||||
hasChanges && !updateConstants.isPending
|
||||
? 'text-white'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{updateConstants.isPending ? 'Saving...' : 'Save'}
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<TagIcon size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No tags available
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mx-4 mb-2 mt-2 rounded-lg bg-blue-50 px-4 py-2">
|
||||
<MyText className="text-center text-xs text-blue-700">
|
||||
Long press and drag to reorder • {tags.length} items
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 px-3 pb-5">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={tags.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{tags.map((tag) => (
|
||||
<SortableTagItem key={tag.id} item={tag} drag={() => {}} isActive={false} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
apps/admin-web/src/routes/dashboard.product-tags.tsx
Normal file
142
apps/admin-web/src/routes/dashboard.product-tags.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowDownUp, Plus, CircleAlert, Tags, LayoutDashboard } from 'lucide-react'
|
||||
import { p as MyText, MyFlatList } from 'web-components'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { TagMenu } from '../components/TagMenu'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import type { ProductTagCore } from '@packages/shared'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/product-tags')({
|
||||
component: ProductTags,
|
||||
})
|
||||
|
||||
interface TagItemData extends ProductTagCore {
|
||||
id: number
|
||||
tagDescription: string | null
|
||||
imageUrl: string | null
|
||||
isDashboardTag: boolean
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
item: TagItemData
|
||||
onDeleteSuccess: () => void
|
||||
}
|
||||
|
||||
const TagItem: React.FC<TagItemProps> = ({ item, onDeleteSuccess }) => (
|
||||
<div className="mb-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex flex-row items-start justify-between">
|
||||
<div className="flex flex-1 flex-row items-start">
|
||||
{item.imageUrl && (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
className="mr-3 mt-1 h-16 w-16 rounded-lg object-cover"
|
||||
alt={item.tagName}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<MyText className="text-lg font-bold text-gray-800">{item.tagName}</MyText>
|
||||
{item.tagDescription && (
|
||||
<MyText className="mt-1 text-sm text-gray-600">{item.tagDescription}</MyText>
|
||||
)}
|
||||
{item.isDashboardTag && (
|
||||
<div className="mt-2 flex flex-row items-center">
|
||||
<LayoutDashboard size={14} color="#16A34A" />
|
||||
<MyText className="ml-1 text-xs text-green-700">Dashboard Tag</MyText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TagMenu tagId={item.id} onDeleteSuccess={onDeleteSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
interface TagHeaderProps {
|
||||
onAddNewTag: () => void
|
||||
onOrderTags: () => void
|
||||
}
|
||||
|
||||
const TagHeader: React.FC<TagHeaderProps> = ({ onAddNewTag, onOrderTags }) => (
|
||||
<div className="mb-4 flex flex-row items-center justify-between border-b border-gray-200 bg-white p-4">
|
||||
<button
|
||||
onClick={onOrderTags}
|
||||
className="flex flex-row items-center rounded-lg bg-gray-100 px-3 py-2"
|
||||
>
|
||||
<ArrowDownUp size={18} color="#374151" />
|
||||
<MyText className="ml-1.5 font-medium text-gray-800">Tag Orders</MyText>
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddNewTag}
|
||||
className="flex flex-row items-center rounded-lg bg-blue-500 px-4 py-2"
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
<MyText className="ml-2 font-medium text-white">Add New Tag</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
function ProductTags() {
|
||||
const navigate = useNavigate()
|
||||
const { data: tagsData, isLoading, error, refetch } = trpc.admin.product.getProductTags.useQuery()
|
||||
|
||||
const tags = tagsData?.tags || []
|
||||
|
||||
useManualRefresh(refetch)
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const handleAddNewTag = () => {
|
||||
navigate({ to: '/dashboard/product-tags/new' })
|
||||
}
|
||||
|
||||
const handleOrderTags = () => {
|
||||
navigate({ to: '/dashboard/product-tags/order' })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-gray-50">
|
||||
<MyText className="text-gray-500">Loading tags...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-4">
|
||||
<CircleAlert size={64} color="#EF4444" />
|
||||
<MyText className="mt-4 text-lg font-semibold text-red-500">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-600">{error?.message || 'Failed to load tags'}</MyText>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
|
||||
>
|
||||
<MyText className="font-medium text-white">Retry</MyText>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 px-4">
|
||||
<MyFlatList
|
||||
data={tags}
|
||||
renderItem={(item) => <TagItem item={item as TagItemData} onDeleteSuccess={refetch} />}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
ListHeaderComponent={<TagHeader onAddNewTag={handleAddNewTag} onOrderTags={handleOrderTags} />}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12">
|
||||
<Tags size={64} color="#D1D5DB" />
|
||||
<MyText className="mt-4 text-lg font-semibold text-gray-500">No tags yet</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-400">Create your first product tag</MyText>
|
||||
</div>
|
||||
}
|
||||
className="pb-4"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
461
apps/admin-web/src/routes/dashboard.products.$id.tsx
Normal file
461
apps/admin-web/src/routes/dashboard.products.$id.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React, { useState, useRef } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Star,
|
||||
CircleAlert,
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Tag,
|
||||
MessageSquareText,
|
||||
BadgeCheck,
|
||||
Reply,
|
||||
X,
|
||||
MessageCircle,
|
||||
Quote,
|
||||
} from 'lucide-react'
|
||||
import { AppContainer, p as MyText, BottomDialog, ImageUploader, ImageCarousel } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
import { Formik } from 'formik'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/products/$id')({
|
||||
component: ProductDetail,
|
||||
})
|
||||
|
||||
interface ReviewResponseFormProps {
|
||||
reviewId: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ReviewResponseForm: React.FC<ReviewResponseFormProps> = ({ reviewId, onClose }) => {
|
||||
const [selectedImages, setSelectedImages] = useState<{ blob: Blob, mimeType: string }[]>([])
|
||||
const [displayImages, setDisplayImages] = useState<{ uri?: string }[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const respondToReview = trpc.admin.product.respondToReview.useMutation()
|
||||
const { upload } = useUploadToObjectStorage()
|
||||
|
||||
const handleFilesSelected = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) {
|
||||
setSelectedImages([])
|
||||
setDisplayImages([])
|
||||
return
|
||||
}
|
||||
const fileArray = Array.from(files)
|
||||
const blobArray = fileArray.map((file) => ({ blob: file, mimeType: file.type || 'image/jpeg' }))
|
||||
setSelectedImages(blobArray)
|
||||
setDisplayImages(fileArray.map((file) => ({ uri: URL.createObjectURL(file) })))
|
||||
}
|
||||
|
||||
const handleRemoveImage = (uri: string) => {
|
||||
const index = displayImages.findIndex((img) => img.uri === uri)
|
||||
if (index !== -1) {
|
||||
const newDisplay = displayImages.filter((_, i) => i !== index)
|
||||
const newFiles = selectedImages.filter((_, i) => i !== index)
|
||||
setDisplayImages(newDisplay)
|
||||
setSelectedImages(newFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (adminResponse: string) => {
|
||||
try {
|
||||
const { keys, presignedUrls } = await upload({
|
||||
images: selectedImages,
|
||||
contextString: 'review',
|
||||
})
|
||||
|
||||
await respondToReview.mutateAsync({
|
||||
reviewId,
|
||||
adminResponse,
|
||||
adminResponseImages: keys,
|
||||
uploadUrls: presignedUrls,
|
||||
})
|
||||
|
||||
window.alert('Response submitted')
|
||||
onClose()
|
||||
setSelectedImages([])
|
||||
setDisplayImages([])
|
||||
} catch (error: any) {
|
||||
window.alert(error.message || 'Failed to submit response.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{ adminResponse: '', images: [] }}
|
||||
onSubmit={(values) => handleSubmit(values.adminResponse)}
|
||||
>
|
||||
{({ handleChange, handleSubmit: formikSubmit, values }) => (
|
||||
<div>
|
||||
<textarea
|
||||
className="mb-4 h-32 w-full rounded-2xl border border-gray-200 bg-gray-50 p-4 text-base text-gray-900 shadow-sm"
|
||||
placeholder="Write your response here..."
|
||||
value={values.adminResponse}
|
||||
onChange={handleChange('adminResponse')}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<MyText className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-700">Attach Images</MyText>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilesSelected(e.target.files)}
|
||||
/>
|
||||
<ImageUploader
|
||||
images={displayImages}
|
||||
existingImageUrls={[]}
|
||||
onAddImage={() => fileInputRef.current?.click()}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => formikSubmit()}
|
||||
disabled={respondToReview.isPending}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-blue-600 to-blue-800 py-4 shadow-lg"
|
||||
>
|
||||
{respondToReview.isPending ? (
|
||||
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||||
) : (
|
||||
<MyText className="text-lg font-bold text-white">Submit Response</MyText>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Formik>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductDetail() {
|
||||
const { id } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const productId = parseInt(id as string)
|
||||
|
||||
const { data: productData, isLoading, error, refetch } = trpc.admin.product.getProductById.useQuery({ id: productId })
|
||||
const { data: reviewsData } = trpc.admin.product.getProductReviews.useQuery({ productId })
|
||||
const [responseDialogOpen, setResponseDialogOpen] = useState(false)
|
||||
const [selectedReview, setSelectedReview] = useState<any>(null)
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const product = productData?.product
|
||||
const defaultSku = product?.skus?.[0]
|
||||
|
||||
const handleEdit = () => {
|
||||
navigate({ to: '/dashboard/products/edit', search: { id: String(productId) } })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 font-medium text-gray-500">Loading...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<CircleAlert size={64} color="#EF4444" />
|
||||
<MyText className="mt-4 text-xl font-bold text-gray-900">Product Not Found</MyText>
|
||||
<button onClick={() => window.history.back()} className="mt-6 rounded-full bg-gray-100 px-8 py-3">
|
||||
<span className="font-semibold text-gray-800">Go Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const averageRating = reviewsData?.reviews.reduce((acc, r) => acc + r.ratings, 0)
|
||||
? (reviewsData!.reviews.reduce((acc, r) => acc + r.ratings, 0) / reviewsData!.reviews.length).toFixed(1)
|
||||
: '-'
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex-1 pb-32">
|
||||
<div className="relative">
|
||||
{defaultSku?.images && defaultSku.images.length > 0 ? (
|
||||
<ImageCarousel
|
||||
images={defaultSku.images.map((url: string) => ({ uri: url }))}
|
||||
className="[&_img]:h-80"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-80 w-full items-center justify-center bg-gray-200" />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/30 to-transparent" />
|
||||
|
||||
<div className="absolute inset-x-0 top-4 z-10 flex flex-row items-center justify-between px-4">
|
||||
<button onClick={() => window.history.back()}>
|
||||
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-black/50 backdrop-blur">
|
||||
<ArrowLeft size={24} color="white" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button onClick={handleEdit}>
|
||||
<div className="flex flex-row items-center overflow-hidden rounded-full bg-black/50 px-4 py-2 backdrop-blur">
|
||||
<Pencil size={16} color="white" />
|
||||
<span className="ml-2 font-bold text-white">Edit</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-8 overflow-hidden rounded-t-[32px] bg-gray-50">
|
||||
<div className="mb-4 rounded-b-[32px] bg-white px-6 pb-6 pt-8 shadow-sm">
|
||||
<div className="mb-2 flex flex-row items-start justify-between">
|
||||
<MyText className="mr-4 flex-1 text-3xl font-extrabold leading-tight text-gray-900">{product.name}</MyText>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-row items-end">
|
||||
<div className="rounded-full border border-blue-100 bg-blue-50 px-3 py-1.5">
|
||||
<span className="text-sm font-bold text-blue-700">
|
||||
{product.skus?.length ?? 0} Variant{(product.skus?.length ?? 0) !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-row border-t border-gray-100 pt-6">
|
||||
<div className="flex flex-1 flex-col items-center border-r border-gray-100">
|
||||
<div className="flex flex-row items-center">
|
||||
<Star size={20} color="#F59E0B" fill="#F59E0B" />
|
||||
<span className="ml-1 text-lg font-bold text-gray-900">{averageRating}</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Rating</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center border-r border-gray-100">
|
||||
<span className="text-lg font-bold text-gray-900">{reviewsData?.reviews.length || 0}</span>
|
||||
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Reviews</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="text-lg font-bold text-gray-900">{product.incrementStep || 1}</span>
|
||||
<span className="mt-1 text-xs font-medium uppercase text-gray-400">Step</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 px-4">
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex flex-row items-center">
|
||||
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-blue-50">
|
||||
<FileText size={22} color="#2563EB" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">Description</span>
|
||||
</div>
|
||||
|
||||
{product.shortDescription && (
|
||||
<p className="mb-3 text-base font-medium leading-relaxed text-gray-900">{product.shortDescription}</p>
|
||||
)}
|
||||
|
||||
<p className="text-base leading-7 text-gray-600">
|
||||
{product.longDescription || 'No detailed description available for this product.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 px-4">
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-4 flex flex-row items-center">
|
||||
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-green-50">
|
||||
<LayoutGrid size={22} color="#059669" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">Variants</span>
|
||||
<div className="ml-3 rounded-full bg-gray-100 px-3 py-1">
|
||||
<span className="text-xs font-bold text-gray-600">{product.skus?.length ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.skus?.map((sku) => (
|
||||
<div key={sku.id} className="mb-3 rounded-2xl border border-gray-200 p-4">
|
||||
{sku.features?.map((f) => (
|
||||
<div key={f.id} className="mb-1 flex flex-row">
|
||||
<span className="w-24 text-sm font-medium text-gray-500">{f.featureName}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{f.featureValue}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-3 flex flex-row items-end justify-between border-t border-gray-100 pt-3">
|
||||
<div>
|
||||
<p className="text-2xl font-black text-gray-900">₹{sku.price}</p>
|
||||
{sku.marketPrice && (
|
||||
<p className="text-sm text-gray-400 line-through">₹{sku.marketPrice}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{sku.isFlashAvailable && (
|
||||
<div className="rounded-full border border-red-200 bg-red-50 px-2 py-1">
|
||||
<span className="text-xs font-bold text-red-600">Flash ₹{sku.flashPrice}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-full px-3 py-1 ${sku.isOutOfStock ? 'bg-red-100' : 'bg-green-100'}`}>
|
||||
<span className={`text-xs font-bold ${sku.isOutOfStock ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{sku.isOutOfStock ? 'Out of Stock' : 'In Stock'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sku.images && sku.images.length > 0 && (
|
||||
<div className="mt-3 flex flex-row gap-2 overflow-x-auto">
|
||||
{sku.images.map((url, idx) => (
|
||||
<img key={idx} src={url} className="h-16 w-16 rounded-lg bg-gray-100 object-cover" alt="" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.deals && product.deals.length > 0 && (
|
||||
<div className="mb-4 px-4">
|
||||
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-amber-50 to-amber-100 p-6 shadow-sm">
|
||||
<div className="mb-4 flex flex-row items-center">
|
||||
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-amber-100">
|
||||
<Tag size={22} color="#D97706" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-amber-900">Special Deals</span>
|
||||
</div>
|
||||
|
||||
{product.deals.map((deal, index) => (
|
||||
<div key={index} className="mb-2 flex flex-row items-center justify-between rounded-2xl border border-amber-200/50 bg-white/60 p-4">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-amber-900">Buy {deal.quantity}</p>
|
||||
<p className="text-xs font-medium text-amber-700">Valid until {new Date(deal.validTill).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<p className="text-2xl font-black text-amber-600">₹{deal.price}</p>
|
||||
<p className="text-xs font-medium text-amber-700">Total Price</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 px-4">
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<div className="mb-6 flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-purple-50">
|
||||
<MessageSquareText size={22} color="#7C3AED" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">Reviews</span>
|
||||
</div>
|
||||
<div className="rounded-full bg-gray-100 px-3 py-1">
|
||||
<span className="text-xs font-bold text-gray-600">{reviewsData?.reviews.length || 0} Total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewsData && reviewsData.reviews.length > 0 ? (
|
||||
reviewsData.reviews.map((review) => (
|
||||
<div key={review.id} className="mb-6 last:mb-0">
|
||||
<div className="flex flex-row items-start">
|
||||
<div className="flex-1 rounded-2xl bg-gray-50 p-4">
|
||||
<div className="mb-2 flex flex-row items-start justify-between">
|
||||
<span className="text-base font-bold text-gray-900">{review.userName}</span>
|
||||
<div className="flex flex-row">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star key={star} size={14} color="#F59E0B" fill={star <= review.ratings ? '#F59E0B' : 'none'} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 leading-relaxed text-gray-700">{review.reviewBody}</p>
|
||||
|
||||
{review.signedImageUrls && review.signedImageUrls.length > 0 && (
|
||||
<div className="mb-3 flex flex-row gap-2 overflow-x-auto">
|
||||
{review.signedImageUrls.map((url, index) => (
|
||||
<img key={index} src={url} className="h-16 w-16 rounded-lg bg-gray-200 object-cover" alt="" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.adminResponse ? (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<BadgeCheck size={14} color="#2563EB" />
|
||||
<span className="ml-1 text-xs font-bold text-blue-700">Admin Response</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-gray-600">{review.adminResponse}</p>
|
||||
{review.signedAdminImageUrls && review.signedAdminImageUrls.length > 0 && (
|
||||
<div className="mt-2 flex flex-row gap-2 overflow-x-auto">
|
||||
{review.signedAdminImageUrls.map((url, index) => (
|
||||
<img key={index} src={url} className="h-12 w-12 rounded-lg bg-white object-cover" alt="" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedReview(review)
|
||||
setResponseDialogOpen(true)
|
||||
}}
|
||||
className="mt-2 flex flex-row items-center self-end rounded-full border border-gray-200 bg-white px-4 py-2 shadow-sm"
|
||||
>
|
||||
<Reply size={16} color="#059669" />
|
||||
<span className="ml-1 text-xs font-bold text-gray-700">Reply</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-10 opacity-50">
|
||||
<MessageCircle size={48} color="#9CA3AF" />
|
||||
<span className="mt-2 font-medium text-gray-400">No reviews yet</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomDialog open={responseDialogOpen} onClose={() => setResponseDialogOpen(false)}>
|
||||
<div className="max-h-[700px] overflow-y-auto p-6">
|
||||
<div className="mb-6 flex flex-row items-center justify-between">
|
||||
<span className="text-2xl font-bold text-gray-900">Reply to Review</span>
|
||||
<button onClick={() => setResponseDialogOpen(false)} className="rounded-full bg-gray-100 p-2">
|
||||
<X size={20} color="#6B7280" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedReview && (
|
||||
<div>
|
||||
<div className="mb-6 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<Quote size={20} color="#9CA3AF" />
|
||||
<span className="ml-1 text-xs font-bold uppercase text-gray-500">Replying to {selectedReview.userName}</span>
|
||||
</div>
|
||||
<MyText numberOfLines={3} className="border-l-2 border-gray-300 pl-2 text-base italic leading-relaxed text-gray-700">
|
||||
{selectedReview.reviewBody}
|
||||
</MyText>
|
||||
</div>
|
||||
<ReviewResponseForm
|
||||
reviewId={selectedReview.id}
|
||||
onClose={() => {
|
||||
setResponseDialogOpen(false)
|
||||
setSelectedReview(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
227
apps/admin-web/src/routes/dashboard.products.edit.tsx
Normal file
227
apps/admin-web/src/routes/dashboard.products.edit.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import React, { useRef, useMemo } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText, type ImageUploaderNeoItem, type ImageUploaderNeoPayload } from 'web-components'
|
||||
import ProductForm, { type ProductFormRef } from '../components/ProductForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useManualRefresh } from '@/lib/refresh-context'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/products/edit')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({ id: (s.id as string) ?? '' }),
|
||||
component: EditProduct,
|
||||
})
|
||||
|
||||
function EditProduct() {
|
||||
const { id } = Route.useSearch()
|
||||
const productId = Number(id)
|
||||
const productFormRef = useRef<ProductFormRef>(null)
|
||||
|
||||
const { data: productResponse, isLoading: isFetching, refetch } = trpc.admin.product.getProductById.useQuery(
|
||||
{ id: productId },
|
||||
{ enabled: !!productId }
|
||||
)
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const updateProduct = trpc.admin.product.updateProduct.useMutation()
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
useManualRefresh(() => refetch())
|
||||
|
||||
const productData = productResponse?.product
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (!productData) {
|
||||
return {
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
storeId: 0,
|
||||
productType: 'item' as const,
|
||||
variants: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: productData.name,
|
||||
shortDescription: productData.shortDescription || '',
|
||||
longDescription: productData.longDescription || '',
|
||||
storeId: productData.storeId || 1,
|
||||
productType: (productData.productType as 'item' | 'combo') || 'item',
|
||||
variants: (productData.skus || []).map((sku) => ({
|
||||
id: sku.id,
|
||||
name: sku.name || '',
|
||||
price: sku.price ? parseFloat(sku.price) : undefined,
|
||||
marketPrice: sku.marketPrice ? parseFloat(sku.marketPrice) : null,
|
||||
isFlashAvailable: sku.isFlashAvailable || false,
|
||||
flashPrice: sku.flashPrice ? parseFloat(sku.flashPrice) : null,
|
||||
isOffer: sku.isOffer || false,
|
||||
isComboOnly: sku.isComboOnly || false,
|
||||
isDeleted: sku.isDeleted || false,
|
||||
isSuspended: sku.isSuspended || false,
|
||||
features: (sku.features || []).map((f) => ({
|
||||
featureName: f.featureName,
|
||||
featureValue: f.featureValue,
|
||||
})),
|
||||
comboItems: (sku.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}, [productData])
|
||||
|
||||
const existingVariantImages = useMemo(() => {
|
||||
if (!productData) return []
|
||||
return (productData.skus || []).map((sku) =>
|
||||
(sku.images || []).map((url) => ({ imgUrl: url, mimeType: null } as ImageUploaderNeoItem))
|
||||
)
|
||||
}, [productData])
|
||||
|
||||
const existingVariantImageKeys = useMemo(() => {
|
||||
if (!productData) return []
|
||||
return (productData.skus || []).map((sku) =>
|
||||
(sku.imageKeys || []).map(String)
|
||||
)
|
||||
}, [productData])
|
||||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], deletedImageKeys: string[]) => {
|
||||
try {
|
||||
for (const variant of values.variants) {
|
||||
const price = variant.price
|
||||
if (price == null || price <= 0) {
|
||||
window.alert('Please enter a valid price for every variant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const seenSignatures = new Set<string>()
|
||||
for (const variant of values.variants) {
|
||||
const features = variant.features || []
|
||||
const hasQuantity = features.some(
|
||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
)
|
||||
if (!hasQuantity) {
|
||||
window.alert('Every SKU must have a quantity feature')
|
||||
return
|
||||
}
|
||||
const signature = features
|
||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (seenSignatures.has(signature)) {
|
||||
window.alert('Two variants have the same attributes')
|
||||
return
|
||||
}
|
||||
seenSignatures.add(signature)
|
||||
}
|
||||
|
||||
const allBlobs: { blob: Blob, mimeType: string }[] = []
|
||||
const imageCounts: number[] = variantImages.map((imgs) =>
|
||||
imgs.filter((img) => img.mimeType !== null).length
|
||||
)
|
||||
|
||||
for (const imgs of variantImages) {
|
||||
for (const img of imgs) {
|
||||
if (img.mimeType === null) continue
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||
}
|
||||
}
|
||||
|
||||
let allUploadUrls: string[] = []
|
||||
if (allBlobs.length > 0) {
|
||||
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||
allUploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
let urlCursor = 0
|
||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||
const count = imageCounts[vIndex]
|
||||
const newUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||
urlCursor += count
|
||||
|
||||
const existingUrls = variantImages[vIndex]
|
||||
?.filter((img) => img.mimeType === null)
|
||||
.map((img) => img.url) || []
|
||||
|
||||
const allUrls = [...existingUrls, ...newUrls]
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
name: variant.name || null,
|
||||
price: variant.price as number,
|
||||
marketPrice: variant.marketPrice ?? undefined,
|
||||
images: allUrls,
|
||||
isFlashAvailable: variant.isFlashAvailable || false,
|
||||
flashPrice: variant.flashPrice ?? undefined,
|
||||
isOffer: variant.isOffer || false,
|
||||
isComboOnly: variant.isComboOnly || false,
|
||||
isDeleted: variant.isDeleted || false,
|
||||
isSuspended: variant.isSuspended || false,
|
||||
features: variant.features.map((attr: any) => ({
|
||||
featureName: attr.featureName,
|
||||
featureValue: attr.featureValue,
|
||||
})),
|
||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
await updateProduct.mutateAsync({
|
||||
id: productId,
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription || undefined,
|
||||
longDescription: values.longDescription || undefined,
|
||||
storeId: values.storeId,
|
||||
productType: values.productType,
|
||||
incrementStep: 1,
|
||||
skus,
|
||||
deletedImageKeys,
|
||||
newImageUrls: allUploadUrls,
|
||||
} as any)
|
||||
|
||||
await refetch()
|
||||
await refetchProducts()
|
||||
window.alert('Product updated successfully!')
|
||||
productFormRef.current?.clearImages()
|
||||
} catch (error: any) {
|
||||
window.alert(error.message || 'Failed to update product')
|
||||
}
|
||||
}
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-600">Loading product...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!productData) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-red-600">Product not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
ref={productFormRef}
|
||||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateProduct.isPending || isUploading}
|
||||
existingVariantImages={existingVariantImages}
|
||||
existingVariantImageKeys={existingVariantImageKeys}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
145
apps/admin-web/src/routes/dashboard.products.new.tsx
Normal file
145
apps/admin-web/src/routes/dashboard.products.new.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, type ImageUploaderNeoPayload } from 'web-components'
|
||||
import ProductForm from '../components/ProductForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useUploadToObjectStorage } from '@/hooks/useUploadToObjectStorage'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/products/new')({
|
||||
component: AddProduct,
|
||||
})
|
||||
|
||||
function AddProduct() {
|
||||
const createProduct = trpc.admin.product.createProduct.useMutation()
|
||||
const { refetch: refetchProducts } = trpc.admin.product.getProducts.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
const { upload, isUploading } = useUploadToObjectStorage()
|
||||
|
||||
const handleSubmit = async (values: any, variantImages: ImageUploaderNeoPayload[][], _deletedImageKeys?: string[]) => {
|
||||
try {
|
||||
for (const variant of values.variants) {
|
||||
const price = variant.price
|
||||
if (price == null || price <= 0) {
|
||||
window.alert('Please enter a valid price for every variant')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const seenSignatures = new Set<string>()
|
||||
for (const variant of values.variants) {
|
||||
const features = variant.features || []
|
||||
const hasQuantity = features.some(
|
||||
(a: any) => (a.featureName ?? '').trim().toLowerCase() === 'quantity'
|
||||
)
|
||||
if (!hasQuantity) {
|
||||
window.alert('Every SKU must have a quantity feature')
|
||||
return
|
||||
}
|
||||
const signature = features
|
||||
.map((a: any) => `${(a.featureName ?? '').trim().toLowerCase()}::${a.featureValue.trim().toLowerCase()}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
if (seenSignatures.has(signature)) {
|
||||
window.alert('Two variants have the same attributes')
|
||||
return
|
||||
}
|
||||
seenSignatures.add(signature)
|
||||
}
|
||||
|
||||
const allBlobs: { blob: Blob, mimeType: string }[] = []
|
||||
const imageCounts: number[] = variantImages.map((imgs) => imgs.length)
|
||||
|
||||
for (const imgs of variantImages) {
|
||||
for (const img of imgs) {
|
||||
const response = await fetch(img.url)
|
||||
const blob = await response.blob()
|
||||
allBlobs.push({ blob, mimeType: img.mimeType || 'image/jpeg' })
|
||||
}
|
||||
}
|
||||
|
||||
let allUploadUrls: string[] = []
|
||||
if (allBlobs.length > 0) {
|
||||
const result = await upload({ images: allBlobs, contextString: 'product_info' })
|
||||
allUploadUrls = result.presignedUrls
|
||||
}
|
||||
|
||||
let urlCursor = 0
|
||||
const skus = values.variants.map((variant: any, vIndex: number) => {
|
||||
const count = imageCounts[vIndex]
|
||||
const variantUrls = allUploadUrls.slice(urlCursor, urlCursor + count)
|
||||
urlCursor += count
|
||||
|
||||
return {
|
||||
name: variant.name || null,
|
||||
price: variant.price as number,
|
||||
marketPrice: variant.marketPrice ?? undefined,
|
||||
images: variantUrls,
|
||||
isFlashAvailable: variant.isFlashAvailable || false,
|
||||
flashPrice: variant.flashPrice ?? undefined,
|
||||
isOffer: variant.isOffer || false,
|
||||
isComboOnly: variant.isComboOnly || false,
|
||||
isDeleted: variant.isDeleted || false,
|
||||
isSuspended: variant.isSuspended || false,
|
||||
features: variant.features.map((attr: any) => ({
|
||||
featureName: attr.featureName,
|
||||
featureValue: attr.featureValue,
|
||||
})),
|
||||
comboItems: (variant.comboItems || []).map((ci: any) => ({
|
||||
skuId: ci.skuId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
await createProduct.mutateAsync({
|
||||
name: values.name,
|
||||
shortDescription: values.shortDescription || undefined,
|
||||
longDescription: values.longDescription || undefined,
|
||||
storeId: values.storeId,
|
||||
productType: values.productType,
|
||||
incrementStep: 1,
|
||||
skus,
|
||||
})
|
||||
|
||||
await refetchProducts()
|
||||
window.alert('Product created successfully!')
|
||||
} catch (error: any) {
|
||||
window.alert(error.message || 'Failed to create product')
|
||||
}
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
shortDescription: '',
|
||||
longDescription: '',
|
||||
storeId: 1,
|
||||
productType: 'item' as const,
|
||||
variants: [
|
||||
{
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
price: undefined,
|
||||
marketPrice: null,
|
||||
isFlashAvailable: false,
|
||||
flashPrice: null,
|
||||
isOffer: false,
|
||||
isComboOnly: false,
|
||||
isDeleted: false,
|
||||
isSuspended: false,
|
||||
features: [{ featureName: 'quantity', featureValue: '' }],
|
||||
comboItems: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<ProductForm
|
||||
mode="create"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createProduct.isPending || isUploading}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
228
apps/admin-web/src/routes/dashboard.products.tsx
Normal file
228
apps/admin-web/src/routes/dashboard.products.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { Image as ImageIcon, Eye, Pencil, Package } from 'lucide-react'
|
||||
import { AppContainer, p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import type { AdminSku } from '@packages/shared'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/products')({
|
||||
component: Products,
|
||||
})
|
||||
|
||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock'
|
||||
|
||||
type SerializedAdminSku = Omit<AdminSku, 'createdAt'> & { createdAt: string | Date }
|
||||
|
||||
function getDefaultSku(product: { skus: SerializedAdminSku[] }): SerializedAdminSku | null {
|
||||
return product.skus?.[0] ?? null
|
||||
}
|
||||
|
||||
function FilterButton({ filter, label, count, activeFilter, onSelect }: { filter: FilterType, label: string, count: number, activeFilter: FilterType, onSelect: (f: FilterType) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(filter)}
|
||||
className={`rounded-lg px-4 py-2 ${activeFilter === filter ? 'bg-blue-500' : 'bg-gray-200'}`}
|
||||
>
|
||||
<MyText className={`${activeFilter === filter ? 'text-white' : 'text-gray-700'} font-semibold`}>
|
||||
{label} ({count})
|
||||
</MyText>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Products() {
|
||||
const navigate = useNavigate()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all')
|
||||
|
||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery()
|
||||
|
||||
useManualRefresh(refetch)
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const products = productsData?.products || []
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter((product) => {
|
||||
const defaultSku = getDefaultSku(product)
|
||||
|
||||
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.shortDescription?.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
|
||||
const matchesFilter = activeFilter === 'all' ||
|
||||
(activeFilter === 'in-stock' && !defaultSku?.isOutOfStock) ||
|
||||
(activeFilter === 'out-of-stock' && defaultSku?.isOutOfStock)
|
||||
|
||||
return matchesSearch && matchesFilter
|
||||
})
|
||||
}, [products, searchTerm, activeFilter])
|
||||
|
||||
const handleEdit = (productId: number) => {
|
||||
navigate({ to: '/dashboard/products/edit', search: { id: String(productId) } })
|
||||
}
|
||||
|
||||
const handleViewDetails = (productId: number) => {
|
||||
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-600">Loading products...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText className="text-red-600">Error loading products</MyText>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const inStockCount = products.filter((p) => getDefaultSku(p) && !getDefaultSku(p)!.isOutOfStock).length
|
||||
const outOfStockCount = products.filter((p) => getDefaultSku(p)?.isOutOfStock).length
|
||||
|
||||
const listHeader = (
|
||||
<>
|
||||
<div className="mb-6 flex flex-row items-center justify-between">
|
||||
<MyText className="text-2xl font-bold text-gray-800">Products</MyText>
|
||||
<MyButton onClick={() => navigate({ to: '/dashboard/products/new' })}>
|
||||
Add Product
|
||||
</MyButton>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search products..."
|
||||
className="mb-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-row gap-2">
|
||||
<FilterButton filter="all" label="All" count={products.length} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
||||
<FilterButton filter="in-stock" label="In Stock" count={inStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
||||
<FilterButton filter="out-of-stock" label="Out of Stock" count={outOfStockCount} activeFilter={activeFilter} onSelect={setActiveFilter} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const renderItem = (product: any) => {
|
||||
const defaultSku = getDefaultSku(product)
|
||||
const skuImages = defaultSku?.images ?? null
|
||||
const isOut = defaultSku?.isOutOfStock ?? false
|
||||
|
||||
return (
|
||||
<div key={product.id} className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg">
|
||||
{skuImages && skuImages.length > 0 ? (
|
||||
<img
|
||||
src={skuImages[0]}
|
||||
className="h-48 w-full object-cover"
|
||||
alt={product.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-48 w-full items-center justify-center bg-gray-200">
|
||||
<ImageIcon size={48} color="#9CA3AF" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-2 flex flex-row items-start justify-between">
|
||||
<MyText className="mr-2 flex-1 text-lg font-bold text-gray-800" numberOfLines={2}>
|
||||
{product.name}
|
||||
</MyText>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className={`mr-1 h-3 w-3 rounded-full ${isOut ? 'bg-red-500' : 'bg-green-500'}`} />
|
||||
<MyText className={`text-sm font-semibold ${isOut ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{isOut ? 'Out' : 'In'}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.shortDescription && (
|
||||
<MyText className="mb-2 text-gray-600" numberOfLines={2}>
|
||||
{product.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<div className="mb-3 flex flex-row items-center justify-between">
|
||||
<MyText className="text-sm font-medium text-gray-500">
|
||||
{product.skus?.length ?? 0} Variants
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<button
|
||||
onClick={() => handleViewDetails(product.id)}
|
||||
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-gray-500 p-3"
|
||||
>
|
||||
<Eye size={16} color="white" />
|
||||
<MyText className="ml-1 font-semibold text-white">View</MyText>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleEdit(product.id)}
|
||||
className="flex flex-1 flex-row items-center justify-center rounded-lg bg-blue-500 p-3"
|
||||
>
|
||||
<Pencil size={16} color="white" />
|
||||
<MyText className="ml-1 font-semibold text-white">Edit</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<MyFlatList
|
||||
data={filteredProducts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
ListHeaderComponent={listHeader}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-10">
|
||||
<Package size={64} color="#D1D5DB" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
{searchTerm || activeFilter !== 'all' ? 'No products match your filters' : 'No products found'}
|
||||
</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-400">
|
||||
{searchTerm || activeFilter !== 'all' ? 'Try adjusting your search or filters' : 'Start by adding your first product'}
|
||||
</MyText>
|
||||
{(searchTerm || activeFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('')
|
||||
setActiveFilter('all')
|
||||
}}
|
||||
className="mt-4 rounded-lg bg-blue-500 px-4 py-2"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Clear Filters</MyText>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="pb-4"
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
248
apps/admin-web/src/routes/dashboard.rebalance.tsx
Normal file
248
apps/admin-web/src/routes/dashboard.rebalance.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CalendarClock, Truck, Snowflake, ShoppingBasket, RefreshCw } from 'lucide-react'
|
||||
import { p as MyText, MyFlatList, BottomDialog, Checkbox, div as MyTouchableOpacity } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/rebalance')({
|
||||
component: RebalanceOrders,
|
||||
})
|
||||
|
||||
interface SlotItemProps {
|
||||
item: any
|
||||
selectedSlots: number[]
|
||||
toggleSlotSelection: (slotId: number) => void
|
||||
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||
item: slot,
|
||||
selectedSlots,
|
||||
toggleSlotSelection,
|
||||
setDialogProducts,
|
||||
setDialogOpen,
|
||||
}) => {
|
||||
const isSelected = selectedSlots.includes(slot.id)
|
||||
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || []
|
||||
const displayProducts = slotProducts.slice(0, 2).join(', ')
|
||||
const isActive = slot.isActive
|
||||
const statusColor = isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
const statusText = isActive ? 'Active' : 'Inactive'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => toggleSlotSelection(slot.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') toggleSlotSelection(slot.id)
|
||||
}}
|
||||
className="mb-4 cursor-pointer rounded-3xl border border-gray-100 bg-white p-5 text-left shadow-sm"
|
||||
>
|
||||
<div className="mb-4 flex-row flex items-center justify-between">
|
||||
<div className="flex-row flex items-center">
|
||||
<span className="mr-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onPress={() => toggleSlotSelection(slot.id)}
|
||||
size={24}
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
className="mr-3 flex h-10 w-10 items-center justify-center rounded-full"
|
||||
style={{ backgroundColor: '#fff0f6' }}
|
||||
>
|
||||
<CalendarClock size={20} color="#F83758" />
|
||||
</div>
|
||||
<div>
|
||||
<MyText className="text-lg font-bold text-gray-900">Slot #{slot.id}</MyText>
|
||||
<MyText className="text-xs text-gray-500">ID: {slot.id}</MyText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-row flex items-center">
|
||||
<span className={`rounded-full px-3 py-1 ${statusColor.split(' ')[0]}`}>
|
||||
<MyText className={`text-xs font-bold ${statusColor.split(' ')[1]}`}>{statusText}</MyText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 h-[1px] bg-gray-100" />
|
||||
|
||||
<div className="flex-row flex flex-wrap">
|
||||
<div className="mb-4 w-1/2 pr-2">
|
||||
<div className="mb-1 flex-row flex items-center">
|
||||
<Truck size={14} color="#6b7280" className="mr-1" />
|
||||
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Delivery</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm font-medium text-gray-800">
|
||||
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-1/2 pl-2">
|
||||
<div className="mb-1 flex-row flex items-center">
|
||||
<Snowflake size={14} color="#6b7280" className="mr-1" />
|
||||
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Freeze</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm font-medium text-gray-800">
|
||||
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slotProducts.length > 0 ? (
|
||||
<div className="mt-1 rounded-xl bg-gray-50 p-3">
|
||||
<div className="flex-row flex items-start">
|
||||
<ShoppingBasket size={16} color="#4b5563" className="mr-2 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<MyText className="mb-0.5 text-xs text-gray-500">Products</MyText>
|
||||
<div className="flex-row flex flex-wrap items-center">
|
||||
<MyText className="text-sm leading-5 text-gray-800">{displayProducts}</MyText>
|
||||
{slotProducts.length > 2 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDialogProducts(slotProducts)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<MyText className="ml-1 text-sm font-semibold" style={{ color: '#2E90FA' }}>
|
||||
+{slotProducts.length - 2} more
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RebalanceOrders() {
|
||||
const [selectedSlots, setSelectedSlots] = useState<number[]>([])
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogProducts, setDialogProducts] = useState<any[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const { data: slotsData, isLoading, refetch: refetchSlots } = trpc.admin.slots.getAll.useQuery()
|
||||
|
||||
const upcomingSlots = slotsData?.slots?.filter(slot => dayjs(slot.deliveryTime).isAfter(dayjs())) || []
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await refetchSlots()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
const { mutate: rebalanceSlots } = trpc.admin.order.rebalanceSlots.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchSlots()
|
||||
},
|
||||
onSettled: () => {
|
||||
window.alert('Rebalance Complete: Slots have been rebalanced.')
|
||||
}
|
||||
})
|
||||
|
||||
const toggleSlotSelection = (slotId: number) => {
|
||||
setSelectedSlots(prev =>
|
||||
prev.includes(slotId)
|
||||
? prev.filter(id => id !== slotId)
|
||||
: [...prev, slotId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleRebalance = () => {
|
||||
window.alert('Rebalancing... Please wait while we rebalance the selected slots.')
|
||||
rebalanceSlots({ slotIds: selectedSlots })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-white">
|
||||
<MyText>Loading slots...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-white">
|
||||
<div className="flex-1 p-4">
|
||||
<MyText className="mb-4 text-xl font-bold text-gray-900">Rebalance Upcoming Slots</MyText>
|
||||
{selectedSlots.length > 0 && (
|
||||
<MyText className="mb-4 text-sm text-gray-600">
|
||||
{selectedSlots.length} slot{selectedSlots.length === 1 ? '' : 's'} selected
|
||||
</MyText>
|
||||
)}
|
||||
{upcomingSlots.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-lg text-gray-600">No upcoming slots available for rebalancing.</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<MyFlatList
|
||||
data={upcomingSlots}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => (
|
||||
<SlotItemComponent
|
||||
item={item}
|
||||
selectedSlots={selectedSlots}
|
||||
toggleSlotSelection={toggleSlotSelection}
|
||||
setDialogProducts={setDialogProducts}
|
||||
setDialogOpen={setDialogOpen}
|
||||
/>
|
||||
)}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-lg text-gray-600">No upcoming slots available for rebalancing.</MyText>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MyTouchableOpacity
|
||||
onClick={handleRebalance}
|
||||
disabled={selectedSlots.length === 0}
|
||||
activeOpacity={0.95}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 32,
|
||||
right: 24,
|
||||
zIndex: 100,
|
||||
opacity: selectedSlots.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
aria-label="Rebalance selected slots"
|
||||
>
|
||||
<span
|
||||
className="flex h-16 w-16 items-center justify-center shadow-lg"
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
background: selectedSlots.length === 0
|
||||
? 'linear-gradient(135deg, #9CA3AF, #6B7280)'
|
||||
: 'linear-gradient(135deg, #F83758, #E91E63)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={32} color="white" />
|
||||
</span>
|
||||
</MyTouchableOpacity>
|
||||
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
<div className="p-4">
|
||||
<MyText className="mb-4 text-lg font-bold">All Products</MyText>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{dialogProducts.map((item, index) => (
|
||||
<div key={index} className="border-b border-gray-200 py-2">
|
||||
<MyText className="text-base">{item}</MyText>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
apps/admin-web/src/routes/dashboard.slots.$id.edit.tsx
Normal file
53
apps/admin-web/src/routes/dashboard.slots.$id.edit.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { p as MyText, AppContainer } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import SlotForm from '../components/SlotForm'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/slots/$id/edit')({
|
||||
component: EditSlot,
|
||||
})
|
||||
|
||||
function EditSlot() {
|
||||
const { id } = Route.useParams()
|
||||
const slotId = parseInt(id as string)
|
||||
|
||||
const { data: slot, isLoading } = trpc.admin.slots.getSlotById.useQuery({ id: slotId })
|
||||
|
||||
const handleSlotUpdated = () => {
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Loading slot...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!slot) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Slot not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<SlotForm
|
||||
initialDeliveryTime={new Date(slot.slot.deliveryTime)}
|
||||
initialFreezeTime={new Date(slot.slot.freezeTime)}
|
||||
initialIsActive={slot.slot.isActive}
|
||||
slotId={slot.slot.id}
|
||||
initialProductIds={slot.slot.products?.map((p: any) => p.id) || []}
|
||||
onSlotAdded={handleSlotUpdated}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
239
apps/admin-web/src/routes/dashboard.slots.detail.tsx
Normal file
239
apps/admin-web/src/routes/dashboard.slots.detail.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { p as MyText, BottomDialog } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import { theme } from '@/lib/theme'
|
||||
import { Code2, ShoppingBag, Link2, Clock, Package, Pencil } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/slots/detail')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
slotId: (s.slotId as string) ?? ''
|
||||
}),
|
||||
component: SlotDetails,
|
||||
})
|
||||
|
||||
function SlotDetails() {
|
||||
const navigate = useNavigate()
|
||||
const { slotId } = Route.useSearch()
|
||||
|
||||
const { data: slotData, isLoading, error } = trpc.admin.slots.getSlotById.useQuery({
|
||||
id: parseInt(slotId as string),
|
||||
})
|
||||
|
||||
const slot = slotData?.slot
|
||||
const products = slot?.products || []
|
||||
const vendorSnippets = slot?.vendorSnippets || []
|
||||
|
||||
// Dialog state for snippet products
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogProducts, setDialogProducts] = useState<any[]>([])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-gray-600">Loading slot details...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !slot) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText className="text-red-600">Error loading slot details</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-white px-4">
|
||||
<div className="flex-1 overflow-y-auto pb-32">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<MyText className="mb-2 block text-2xl font-black text-gray-900">
|
||||
Slot #{slot.id}
|
||||
</MyText>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<MyText className="mb-1 block text-sm text-gray-600">
|
||||
Delivery: {dayjs(slot.deliveryTime).format('MMM DD, YYYY hh:mm A')}
|
||||
</MyText>
|
||||
<MyText className="block text-sm text-gray-600">
|
||||
Freeze: {dayjs(slot.freezeTime).format('MMM DD, YYYY hh:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 ${slot.isActive ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<MyText className={`text-xs font-bold ${slot.isActive ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{slot.isActive ? 'Active' : 'Inactive'}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Snippets Section */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex flex-row items-center justify-between">
|
||||
<MyText className="text-lg font-bold text-gray-900">Vendor Snippets</MyText>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">
|
||||
<MyText className="text-xs font-medium text-gray-600">{vendorSnippets.length} snippets</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{vendorSnippets.length === 0 ? (
|
||||
<div className="items-center rounded-lg bg-gray-50 p-6 text-center">
|
||||
<Code2 size={32} color="#9CA3AF" className="mx-auto" />
|
||||
<MyText className="mt-2 block text-center text-gray-500">No vendor snippets for this slot</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{vendorSnippets.map((snippet, index) => {
|
||||
const isExpired = snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs())
|
||||
|
||||
return (
|
||||
<div key={snippet.id} className="rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-3 flex flex-row items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<MyText className="block font-bold text-gray-900" numberOfLines={1}>
|
||||
{snippet.snippetCode}
|
||||
</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<span className={`rounded-full px-2 py-1 ${isExpired ? 'bg-red-100' : 'bg-green-100'}`}>
|
||||
<MyText className={`text-xs font-medium ${isExpired ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{isExpired ? 'Expired' : 'Active'}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
const snippetProducts = products.filter((p) => snippet.skuIds.includes(p.id))
|
||||
setDialogProducts(snippetProducts)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
className="mr-4 flex flex-row items-center"
|
||||
>
|
||||
<ShoppingBag size={14} color="#94A3B8" />
|
||||
<MyText className="ml-1 text-xs font-medium text-blue-600 underline">
|
||||
{snippet.skuIds.length} products
|
||||
</MyText>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ text: snippet.accessUrl })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(snippet.accessUrl)
|
||||
window.alert('Link copied to clipboard')
|
||||
}
|
||||
} catch (err) {
|
||||
ErrorToast('Failed to share link')
|
||||
}
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Link2 size={14} color={theme.colors.brand500} />
|
||||
<MyText className="ml-1 text-xs font-medium text-brand-600">
|
||||
Share
|
||||
</MyText>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{snippet.validTill && (
|
||||
<span className="flex flex-row items-center">
|
||||
<Clock size={14} color="#94A3B8" />
|
||||
<MyText className="ml-1 text-xs font-medium text-gray-600">
|
||||
Expires {dayjs(snippet.validTill).format('MMM DD')}
|
||||
</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snippet.createdAt && (
|
||||
<div className="mt-2 border-t border-gray-200 pt-2">
|
||||
<MyText className="block text-xs text-gray-500">
|
||||
Created {dayjs(snippet.createdAt).format('MMM DD, YYYY')}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products Section */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex flex-row items-center justify-between">
|
||||
<MyText className="text-lg font-bold text-gray-900">Products</MyText>
|
||||
<span className="rounded-full bg-gray-100 px-2 py-1">
|
||||
<MyText className="text-xs font-medium text-gray-600">{products.length} items</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="items-center rounded-lg bg-gray-50 p-6 text-center">
|
||||
<Package size={32} color="#9CA3AF" className="mx-auto" />
|
||||
<MyText className="mt-2 block text-center text-gray-500">No products in this slot</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row flex-wrap gap-2 py-2">
|
||||
{products.map((product) => (
|
||||
<span key={product.id} className="rounded-full border border-blue-200 bg-blue-50 px-3 py-2">
|
||||
<MyText className="text-sm font-medium text-blue-700" numberOfLines={1}>
|
||||
{product.name}
|
||||
</MyText>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAB for Edit Slot */}
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/slots/$id/edit', params: { id: String(slot.id) } })}
|
||||
aria-label="Edit slot"
|
||||
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
|
||||
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
|
||||
>
|
||||
<Pencil size={24} color="white" />
|
||||
</button>
|
||||
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
<div className="py-4">
|
||||
<MyText className="mb-4 block text-center text-lg font-semibold">Snippet Products</MyText>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{dialogProducts.length === 0 ? (
|
||||
<div className="items-center px-4 py-8 text-center">
|
||||
<MyText className="block text-center text-gray-500">
|
||||
No products found for this snippet
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
dialogProducts.map((product) => (
|
||||
<div key={product.id} className="border-b border-gray-100 px-4 py-3">
|
||||
<MyText className="block text-base font-medium text-gray-900">
|
||||
{product.name}
|
||||
</MyText>
|
||||
{product.shortDescription && (
|
||||
<MyText className="mt-1 block text-sm text-gray-600">
|
||||
{product.shortDescription}
|
||||
</MyText>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
apps/admin-web/src/routes/dashboard.slots.new.tsx
Normal file
48
apps/admin-web/src/routes/dashboard.slots.new.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { p as MyText, AppContainer } from 'web-components'
|
||||
import SlotForm from '../components/SlotForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/slots/new')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
baseslot: (s.baseslot as string) ?? ''
|
||||
}),
|
||||
component: AddSlot,
|
||||
})
|
||||
|
||||
function AddSlot() {
|
||||
const { baseslot } = Route.useSearch()
|
||||
const baseSlotId = baseslot ? parseInt(baseslot as string) : null
|
||||
|
||||
const { refetch } = trpc.admin.slots.getAll.useQuery()
|
||||
const { data: baseSlotData, isLoading } = trpc.admin.slots.getSlotById.useQuery(
|
||||
{ id: baseSlotId! },
|
||||
{ enabled: !!baseSlotId }
|
||||
)
|
||||
|
||||
const handleSlotAdded = () => {
|
||||
refetch()
|
||||
window.history.back()
|
||||
}
|
||||
|
||||
if (isLoading && baseSlotId) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Loading base slot...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<SlotForm
|
||||
onSlotAdded={handleSlotAdded}
|
||||
initialProductIds={baseSlotData?.slot?.products?.map((p: any) => p.id) || []}
|
||||
initialGroupIds={baseSlotData?.slot?.groupIds || []}
|
||||
/>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
307
apps/admin-web/src/routes/dashboard.slots.tsx
Normal file
307
apps/admin-web/src/routes/dashboard.slots.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { CalendarClock, Pencil, EllipsisVertical, Plus, Truck, Snowflake, ShoppingBasket, Copy, X, Package } from 'lucide-react'
|
||||
import { p as MyText, BottomDialog, Checkbox, MyFlatList } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/slots')({
|
||||
component: Slots,
|
||||
})
|
||||
|
||||
interface SlotItemProps {
|
||||
item: any,
|
||||
setDialogProducts: React.Dispatch<React.SetStateAction<any[]>>,
|
||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
const SlotItemComponent: React.FC<SlotItemProps> = ({
|
||||
item: slot,
|
||||
setDialogProducts,
|
||||
setDialogOpen,
|
||||
refetch,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const slotProducts = slot.products?.map((p: any) => p.name).filter(Boolean) || []
|
||||
const displayProducts = slotProducts.slice(0, 2).join(', ')
|
||||
|
||||
const isActive = slot.isActive
|
||||
const statusBg = isActive ? 'bg-green-100' : 'bg-red-100'
|
||||
const statusTextColor = isActive ? 'text-green-700' : 'text-red-700'
|
||||
const statusText = isActive ? 'Active' : 'Inactive'
|
||||
|
||||
const updateSlotCapacity = trpc.admin.slots.updateSlotCapacity.useMutation()
|
||||
|
||||
const handleCapacityToggle = () => {
|
||||
updateSlotCapacity.mutate(
|
||||
{ slotId: slot.id, isCapacityFull: !slot.isCapacityFull },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMenuOpen(false)
|
||||
refetch()
|
||||
window.alert(
|
||||
slot.isCapacityFull
|
||||
? 'Success: Slot capacity reset. It will now be visible to users.'
|
||||
: 'Success: Slot marked as full capacity. It will be hidden from users.'
|
||||
)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to update slot capacity')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate({ to: '/dashboard/slots/detail', search: { slotId: String(slot.id) } })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') navigate({ to: '/dashboard/slots/detail', search: { slotId: String(slot.id) } }) }}
|
||||
className="w-full cursor-pointer text-left"
|
||||
>
|
||||
<div className="mb-4 rounded-3xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
{/* Header: ID and Status */}
|
||||
<div className="mb-4 flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-[#fff0f6]">
|
||||
<CalendarClock size={20} color="#F83758" />
|
||||
</span>
|
||||
<div>
|
||||
<MyText className="block text-lg font-bold text-gray-900">Slot #{slot.id}</MyText>
|
||||
<MyText className="block text-xs text-gray-500">ID: {slot.id}</MyText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), navigate({ to: '/dashboard/slots/$id/edit', params: { id: String(slot.id) } }) }}
|
||||
className="mr-2 rounded-full bg-[#fff0f6] px-3 py-1"
|
||||
>
|
||||
<span className="flex flex-row items-center">
|
||||
<Pencil size={12} color="#F83758" className="mr-1" />
|
||||
<MyText className="text-xs font-bold text-brand-500">Edit</MyText>
|
||||
</span>
|
||||
</button>
|
||||
<span className={`rounded-full px-3 py-1 ${statusBg}`}>
|
||||
<MyText className={`text-xs font-bold ${statusTextColor}`}>{statusText}</MyText>
|
||||
</span>
|
||||
{slot.isCapacityFull && (
|
||||
<span className="ml-2 rounded-full bg-red-500 px-2 py-1">
|
||||
<MyText className="text-xs font-bold text-white">FULL</MyText>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(), setMenuOpen(true) }}
|
||||
className="ml-2 p-1"
|
||||
aria-label="Slot actions"
|
||||
>
|
||||
<EllipsisVertical size={20} color="#9CA3AF" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replicate Menu Dialog */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<BottomDialog open={menuOpen} onClose={() => setMenuOpen(false)}>
|
||||
<div className="p-4">
|
||||
<MyText className="mb-4 block text-lg font-bold">Slot #{slot.id} Actions</MyText>
|
||||
|
||||
{/* Capacity Toggle */}
|
||||
<button
|
||||
onClick={handleCapacityToggle}
|
||||
disabled={updateSlotCapacity.isPending}
|
||||
className="w-full border-b border-gray-200 py-4 text-left disabled:opacity-50"
|
||||
>
|
||||
<span className="flex flex-row items-center justify-between">
|
||||
<span className="flex flex-1 flex-row items-center">
|
||||
{updateSlotCapacity.isPending ? (
|
||||
<span className="mr-3 h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-red-500" />
|
||||
) : (
|
||||
<Package size={20} color={slot.isCapacityFull ? '#EF4444' : '#4B5563'} className="mr-3" />
|
||||
)}
|
||||
<span>
|
||||
<MyText className="block text-base text-gray-800">Mark as Full Capacity</MyText>
|
||||
<MyText className="mt-0.5 block text-xs text-gray-500">
|
||||
{slot.isCapacityFull
|
||||
? 'Slot is hidden from users'
|
||||
: 'Hidden from users when full'}
|
||||
</MyText>
|
||||
</span>
|
||||
</span>
|
||||
{updateSlotCapacity.isPending ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-red-500" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={slot.isCapacityFull}
|
||||
onPress={handleCapacityToggle}
|
||||
size={22}
|
||||
fillColor="#EF4444"
|
||||
checkColor="#FFFFFF"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
navigate({ to: '/dashboard/slots/new', search: { baseslot: String(slot.id) } })
|
||||
}}
|
||||
className="w-full border-b border-gray-200 py-4 text-left"
|
||||
>
|
||||
<span className="flex flex-row items-center">
|
||||
<Copy size={20} color="#4B5563" className="mr-3" />
|
||||
<MyText className="text-base text-gray-800">Replicate Slot</MyText>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="mt-2 w-full py-4 text-left"
|
||||
>
|
||||
<span className="flex flex-row items-center">
|
||||
<X size={20} color="#EF4444" className="mr-3" />
|
||||
<MyText className="text-base text-red-500">Cancel</MyText>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mb-4 h-[1px] bg-gray-100" />
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="flex flex-row flex-wrap">
|
||||
{/* Delivery Time */}
|
||||
<div className="mb-4 w-1/2 pr-2">
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<Truck size={14} color="#6b7280" className="mr-1" />
|
||||
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Delivery</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm font-medium text-gray-800">
|
||||
{dayjs(slot.deliveryTime).format('DD MMM, h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{/* Freeze Time */}
|
||||
<div className="mb-4 w-1/2 pl-2">
|
||||
<div className="mb-1 flex flex-row items-center">
|
||||
<Snowflake size={14} color="#6b7280" className="mr-1" />
|
||||
<MyText className="text-xs font-semibold uppercase tracking-wider text-gray-500">Freeze</MyText>
|
||||
</div>
|
||||
<MyText className="text-sm font-medium text-gray-800">
|
||||
{dayjs(slot.freezeTime).format('DD MMM, h:mm A')}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
{slotProducts.length > 0 ? (
|
||||
<div className="mt-1 rounded-xl bg-gray-50 p-3">
|
||||
<div className="flex flex-row items-start">
|
||||
<ShoppingBasket size={16} color="#4b5563" className="mr-2 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<MyText className="mb-0.5 block text-xs text-gray-500">Products</MyText>
|
||||
<div className="flex flex-row flex-wrap items-center">
|
||||
<MyText className="text-sm leading-5 text-gray-800">{displayProducts}</MyText>
|
||||
{slotProducts.length > 2 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDialogProducts(slotProducts)
|
||||
setDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<MyText className="ml-1 text-sm font-semibold text-brand-500">
|
||||
+{slotProducts.length - 2} more
|
||||
</MyText>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Slots() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { data: slotsData, isLoading, refetch } = trpc.admin.slots.getAll.useQuery()
|
||||
|
||||
const slots = slotsData?.slots || []
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [dialogProducts, setDialogProducts] = useState<any[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
if (location.pathname !== '/dashboard/slots') {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-white">
|
||||
<MyText>Loading slots...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 bg-white">
|
||||
<MyFlatList
|
||||
data={slots}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item) => (
|
||||
<SlotItemComponent
|
||||
item={item}
|
||||
setDialogProducts={setDialogProducts}
|
||||
setDialogOpen={setDialogOpen}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
className="p-4"
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
|
||||
{/* FAB for Add New Slot */}
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/slots/new', search: { baseslot: '' } })}
|
||||
data-testid="add-slot-fab"
|
||||
aria-label="add-slot-fab"
|
||||
className="fixed flex h-16 w-16 items-center justify-center rounded-[24px] shadow-lg"
|
||||
style={{ bottom: 32, right: 24, zIndex: 100, background: 'linear-gradient(135deg,#F83758,#E91E63)' }}
|
||||
>
|
||||
<Plus size={32} color="white" />
|
||||
</button>
|
||||
|
||||
{/* Products Dialog */}
|
||||
<BottomDialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
|
||||
<div className="p-4">
|
||||
<MyText className="mb-4 block text-lg font-bold">All Products</MyText>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{dialogProducts.map((item, index) => (
|
||||
<div key={index} className="border-b border-gray-200 py-2">
|
||||
<MyText className="text-base">{item}</MyText>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BottomDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
apps/admin-web/src/routes/dashboard.stores.edit.tsx
Normal file
83
apps/admin-web/src/routes/dashboard.stores.edit.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import StoreForm, { type StoreFormData } from '../components/StoreForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/stores/edit')({
|
||||
validateSearch: (s: Record<string, unknown>) => ({
|
||||
id: (s.id as string) ?? ''
|
||||
}),
|
||||
component: EditStore,
|
||||
})
|
||||
|
||||
function EditStore() {
|
||||
const navigate = useNavigate()
|
||||
const { id } = Route.useSearch()
|
||||
|
||||
const storeId = parseInt(id as string)
|
||||
|
||||
const { data: storeData, isLoading: isLoadingStore, refetch } = trpc.admin.store.getStoreById.useQuery(
|
||||
{ id: storeId },
|
||||
{ enabled: !!storeId }
|
||||
)
|
||||
|
||||
const updateStoreMutation = trpc.admin.store.updateStore.useMutation()
|
||||
|
||||
const handleSubmit = (values: StoreFormData) => {
|
||||
updateStoreMutation.mutate({ id: storeId, ...values }, {
|
||||
onSuccess: (data) => {
|
||||
refetch()
|
||||
window.alert(`Success: ${data.message}`)
|
||||
navigate({ to: '/dashboard/stores' } as any)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to update store')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoadingStore) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Loading store...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (!storeData?.store) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<MyText>Store not found</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
name: storeData.store.name,
|
||||
description: storeData.store.description || '',
|
||||
imageUrl: storeData.store.imageUrl || '',
|
||||
owner: storeData.store.owner.id,
|
||||
products: [], // Will be set by StoreForm
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div>
|
||||
<MyText className="mb-6 block text-2xl font-bold text-gray-800">Edit Store</MyText>
|
||||
<StoreForm
|
||||
mode="edit"
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={updateStoreMutation.isPending}
|
||||
storeId={storeId}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
46
apps/admin-web/src/routes/dashboard.stores.new.tsx
Normal file
46
apps/admin-web/src/routes/dashboard.stores.new.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText } from 'web-components'
|
||||
import StoreForm, { type StoreFormData } from '../components/StoreForm'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/stores/new')({
|
||||
component: AddStore,
|
||||
})
|
||||
|
||||
function AddStore() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const createStoreMutation = trpc.admin.store.createStore.useMutation()
|
||||
const { refetch: refetchStores } = trpc.admin.store.getStores.useQuery(undefined, {
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const handleSubmit = (values: StoreFormData) => {
|
||||
createStoreMutation.mutate(values, {
|
||||
onSuccess: async (data) => {
|
||||
await refetchStores()
|
||||
window.alert(`Success: ${data.message}`)
|
||||
navigate({ to: '/dashboard/stores' }) // Navigate back to stores list
|
||||
},
|
||||
onError: (error: any) => {
|
||||
ErrorToast(error.message || 'Failed to create store')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div>
|
||||
<MyText className="mb-6 block text-2xl font-bold text-gray-800">Add New Store</MyText>
|
||||
<StoreForm
|
||||
mode="create"
|
||||
initialValues={{ name: '', description: '', owner: 0, products: [] }}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={createStoreMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
199
apps/admin-web/src/routes/dashboard.stores.tsx
Normal file
199
apps/admin-web/src/routes/dashboard.stores.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { Store as StoreIcon, Trash2, Plus, ArrowRight, CircleAlert } from 'lucide-react'
|
||||
import { p as MyText, MyButton } from 'web-components'
|
||||
import { useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { ErrorToast } from '@/services/toaster'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/stores')({
|
||||
component: Stores,
|
||||
})
|
||||
|
||||
interface StoreItemProps {
|
||||
item: any,
|
||||
isEditMode: boolean,
|
||||
onEdit: (storeId: number) => void,
|
||||
onDelete: (storeId: number) => void
|
||||
}
|
||||
|
||||
const StoreItem: React.FC<StoreItemProps> = ({ item, isEditMode, onEdit, onDelete }) => {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="relative flex-1 overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||||
{/* Delete Button - Only visible in edit mode */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="absolute right-2 top-2 z-10 rounded-full bg-red-500 p-2 shadow-lg"
|
||||
aria-label="Delete store"
|
||||
>
|
||||
<Trash2 size={16} color="white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={isEditMode ? undefined : () => onEdit(item.id)}
|
||||
role={isEditMode ? undefined : 'button'}
|
||||
tabIndex={isEditMode ? undefined : 0}
|
||||
onKeyDown={isEditMode ? undefined : (e) => { if (e.key === 'Enter') onEdit(item.id) }}
|
||||
className={`flex-1 ${isEditMode ? '' : 'cursor-pointer'}`}
|
||||
>
|
||||
{/* Image Section - Portrait oriented */}
|
||||
<div className="relative h-40 w-full bg-gray-50">
|
||||
{item.imageUrl ? (
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
className="h-full w-full object-cover"
|
||||
alt={item.name}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<StoreIcon size={32} color="#E5E7EB" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Dot */}
|
||||
<span className="absolute right-2 top-2 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="p-3">
|
||||
<MyText
|
||||
className="mb-1 block text-base font-bold leading-5 text-gray-900"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</MyText>
|
||||
|
||||
<div className="mb-2 flex flex-row items-center">
|
||||
<MyText className="text-xs font-medium text-gray-400">ID: #{item.id}</MyText>
|
||||
<span className="mx-1.5 h-1 w-1 rounded-full bg-gray-300" />
|
||||
<MyText className="text-xs font-medium text-gray-400">
|
||||
{item.createdAt ? new Date(item.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : 'N/A'}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="flex flex-row items-center justify-between border-t border-gray-50 pt-2">
|
||||
<MyText className="text-[10px] font-bold uppercase tracking-wide text-blue-600">View Details</MyText>
|
||||
<ArrowRight size={12} color="#2563EB" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stores() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
|
||||
const { data: storesData, isLoading, error, refetch } = trpc.admin.store.getStores.useQuery()
|
||||
const { mutateAsync: deleteStoreAsync } = trpc.admin.store.deleteStore.useMutation()
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const stores = storesData?.stores || []
|
||||
|
||||
const handleEdit = (storeId: number) => {
|
||||
navigate({ to: '/dashboard/stores/edit', search: { id: String(storeId) } })
|
||||
}
|
||||
|
||||
const handleDelete = (storeId: number) => {
|
||||
if (!window.confirm('Delete Store: Are you sure you want to delete this store? This action cannot be undone. All products will be unassigned from this store.')) return
|
||||
;(async () => {
|
||||
try {
|
||||
await deleteStoreAsync({ storeId })
|
||||
refetch()
|
||||
window.alert('Success: Store deleted successfully')
|
||||
} catch (err: any) {
|
||||
ErrorToast(err.message || 'Failed to delete store')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if (location.pathname !== '/dashboard/stores') {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<MyText className="mt-4 font-medium text-gray-500">Loading stores...</MyText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-50 p-6">
|
||||
<span className="mb-4 rounded-full bg-red-50 p-6">
|
||||
<CircleAlert size={48} color="#EF4444" />
|
||||
</span>
|
||||
<MyText className="mb-2 block text-xl font-bold text-gray-900">Oops!</MyText>
|
||||
<MyText className="mb-6 block text-center text-gray-600">We couldn't load the stores. Please try again.</MyText>
|
||||
<MyButton onClick={() => refetch()} variant="red" className="rounded-xl px-8 py-3">
|
||||
Retry
|
||||
</MyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-1 flex-col bg-gray-50">
|
||||
<div className="mb-8 mt-4 px-3">
|
||||
<div className="mb-2 flex flex-row items-end justify-between">
|
||||
<div>
|
||||
<MyText className="block text-3xl font-bold tracking-tight text-gray-800">Stores</MyText>
|
||||
<MyText className="mt-1 block text-base font-medium text-gray-500">Manage your outlets</MyText>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<button
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
className="mr-2 rounded-full border border-gray-200 bg-white px-4 py-2 shadow-sm"
|
||||
>
|
||||
<MyText className="text-sm font-semibold text-gray-600">
|
||||
{isEditMode ? 'Done' : 'Edit'}
|
||||
</MyText>
|
||||
</button>
|
||||
<span className="rounded-full border border-gray-200 bg-white px-4 py-1.5 shadow-sm">
|
||||
<MyText className="text-xs font-semibold text-gray-600">{stores.length} Locations</MyText>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stores.length === 0 ? (
|
||||
<div className="mt-20 flex flex-1 flex-col items-center justify-center">
|
||||
<StoreIcon size={80} color="#E5E7EB" />
|
||||
<MyText className="mt-4 block text-lg font-medium text-gray-400">No stores found</MyText>
|
||||
<MyText className="mt-2 block max-w-[250px] text-center text-sm text-gray-400">Get started by adding your first store location.</MyText>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 p-2 pb-24">
|
||||
{stores.map((item) => (
|
||||
<StoreItem key={item.id.toString()} item={item} isEditMode={isEditMode} onEdit={handleEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Action Button for Adding New Store */}
|
||||
<div className="fixed bottom-8 right-6 shadow-xl">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/dashboard/stores/new' })}
|
||||
aria-label="Add new store"
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: 'linear-gradient(135deg,#2563EB,#1D4ED8)' }}
|
||||
>
|
||||
<Plus size={32} color="white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
apps/admin-web/src/routes/dashboard.tsx
Normal file
190
apps/admin-web/src/routes/dashboard.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute, Outlet, useNavigate, useLocation, Navigate } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
DollarSign,
|
||||
Group,
|
||||
Images,
|
||||
CalendarClock,
|
||||
TriangleAlert,
|
||||
ShoppingBag,
|
||||
TicketPercent,
|
||||
Code2,
|
||||
Store,
|
||||
Users,
|
||||
Megaphone,
|
||||
LogOut,
|
||||
Menu,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
UserRound,
|
||||
} from 'lucide-react'
|
||||
import { p as MyText } from 'web-components'
|
||||
import { useStaffAuth } from '../components/context/staff-auth-context'
|
||||
import { EventBus, REFRESH_EVENT } from '../lib/event-bus'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
component: DashboardShell,
|
||||
})
|
||||
|
||||
const SCREEN_TITLES: Record<string, string> = {
|
||||
products: 'Products',
|
||||
prices: 'Prices Overview',
|
||||
'product-groupings': 'Product Groupings',
|
||||
banners: 'Dashboard Banners',
|
||||
slots: 'Slots',
|
||||
complaints: 'Complaints',
|
||||
orders: 'Manage Orders',
|
||||
coupons: 'Coupons',
|
||||
'vendor-snippets': 'Vendor Snippets',
|
||||
stores: 'Stores',
|
||||
users: 'User Management',
|
||||
notifications: 'Send Notifications',
|
||||
'product-tags': 'Product Tags',
|
||||
rebalance: 'Rebalance Orders',
|
||||
'customize-app': 'Customize App',
|
||||
}
|
||||
|
||||
interface SidebarEntry {
|
||||
label: string
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
action?: 'logout'
|
||||
}
|
||||
|
||||
const SIDEBAR_ENTRIES: SidebarEntry[] = [
|
||||
{ label: 'Dashboard', to: '/dashboard', icon: <LayoutDashboard size={22} /> },
|
||||
{ label: 'Products', to: '/dashboard/products', icon: <Package size={22} /> },
|
||||
{ label: 'Prices Overview', to: '/dashboard/prices', icon: <DollarSign size={22} /> },
|
||||
{ label: 'Product Groupings', to: '/dashboard/product-groupings', icon: <Group size={22} /> },
|
||||
{ label: 'Dashboard Banners', to: '/dashboard/banners', icon: <Images size={22} /> },
|
||||
{ label: 'Slots', to: '/dashboard/slots', icon: <CalendarClock size={22} /> },
|
||||
{ label: 'Complaints', to: '/dashboard/complaints', icon: <TriangleAlert size={22} /> },
|
||||
{ label: 'Manage Orders', to: '/dashboard/orders', icon: <ShoppingBag size={22} /> },
|
||||
{ label: 'Coupons', to: '/dashboard/coupons', icon: <TicketPercent size={22} /> },
|
||||
{ label: 'Vendor Snippets', to: '/dashboard/vendor-snippets', icon: <Code2 size={22} /> },
|
||||
{ label: 'Stores', to: '/dashboard/stores', icon: <Store size={22} /> },
|
||||
{ label: 'User Management', to: '/dashboard/users', icon: <Users size={22} /> },
|
||||
{ label: 'Send Notifications', to: '/dashboard/notifications', icon: <Megaphone size={22} /> },
|
||||
{ label: 'Logout', to: '/login', icon: <LogOut size={22} />, action: 'logout' },
|
||||
]
|
||||
|
||||
// Top-level feature roots show the menu toggle; deeper screens show back.
|
||||
const FEATURE_ROOTS = new Set([
|
||||
'/dashboard',
|
||||
...SIDEBAR_ENTRIES.filter((e) => !e.action).map((e) => e.to),
|
||||
])
|
||||
|
||||
function DashboardShell() {
|
||||
const { isLoggedIn, isLoading, staff, logout } = useStaffAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-white">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" />
|
||||
}
|
||||
|
||||
const pathname = location.pathname
|
||||
const segment = pathname.split('/')[2] ?? ''
|
||||
const title = segment ? (SCREEN_TITLES[segment] ?? 'Dashboard') : 'Dashboard'
|
||||
const canGoBack = !FEATURE_ROOTS.has(pathname)
|
||||
|
||||
const goEntry = (entry: SidebarEntry) => {
|
||||
if (entry.action === 'logout') {
|
||||
logout()
|
||||
} else if (entry.to === '/dashboard') {
|
||||
navigate({ to: '/dashboard' })
|
||||
} else {
|
||||
navigate({ to: entry.to } as any)
|
||||
}
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
const isActive = (to: string) =>
|
||||
to === '/dashboard' ? pathname === '/dashboard' : pathname.startsWith(to)
|
||||
|
||||
const sidebar = (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-white">
|
||||
{staff && (
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<UserRound size={40} color="#3B82F6" />
|
||||
<div className="mt-2">
|
||||
<MyText className="text-lg font-semibold text-gray-800">{staff.name}</MyText>
|
||||
<MyText className="text-sm text-gray-600">Staff Member</MyText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<nav className="flex-1 py-2">
|
||||
{SIDEBAR_ENTRIES.map((entry) => (
|
||||
<button
|
||||
key={entry.label}
|
||||
onClick={() => goEntry(entry)}
|
||||
className={`flex w-full items-center gap-4 px-4 py-3 text-left text-[15px] font-medium transition-colors ${
|
||||
!entry.action && isActive(entry.to)
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className={!entry.action && isActive(entry.to) ? 'text-blue-600' : 'text-gray-500'}>
|
||||
{entry.icon}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white md:grid md:grid-cols-[260px_1fr]">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden border-r border-gray-200 md:block">{sidebar}</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setSidebarOpen(false)} />
|
||||
<aside className="absolute inset-y-0 left-0 w-[280px] bg-white shadow-xl">{sidebar}</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{/* Header — same chrome as admin-ui drawer header */}
|
||||
<header
|
||||
className="sticky top-0 z-40 flex items-center justify-between px-4 py-3"
|
||||
style={{ backgroundColor: '#fdfdfd' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => (canGoBack ? window.history.back() : setSidebarOpen(true))}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f2f2]"
|
||||
aria-label={canGoBack ? 'Go back' : 'Open menu'}
|
||||
>
|
||||
{canGoBack ? <ArrowLeft size={24} color="black" /> : <Menu size={24} color="black" />}
|
||||
</button>
|
||||
<MyText className="text-lg font-semibold text-gray-900">{title}</MyText>
|
||||
<button
|
||||
onClick={() => EventBus.emit(REFRESH_EVENT)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f2f2]"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={24} color="black" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
250
apps/admin-web/src/routes/dashboard.users.$id.tsx
Normal file
250
apps/admin-web/src/routes/dashboard.users.$id.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import type { OrderCardActionProps } from '@packages/shared'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { ChevronLeft, ChevronRight, CircleAlert, User as UserIcon, Clock, ShoppingBag } from 'lucide-react'
|
||||
import { AppContainer, p as MyText, Checkbox } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import dayjs from 'dayjs'
|
||||
import { UserIncidentsView } from '../components/UserIncidentsView'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/users/$id')({
|
||||
component: UserDetails,
|
||||
})
|
||||
|
||||
interface Order {
|
||||
id: number
|
||||
readableId: number
|
||||
totalAmount: string
|
||||
createdAt: string
|
||||
status: string
|
||||
isFlashDelivery: boolean
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
interface OrderItemProps extends OrderCardActionProps<Order> {
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'delivered':
|
||||
return 'text-green-600 bg-green-50 border-green-100'
|
||||
case 'cancelled':
|
||||
return 'text-red-600 bg-red-50 border-red-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
const OrderItem: React.FC<OrderItemProps> = ({ order, onPress }) => {
|
||||
const statusStyle = getStatusColor(order.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onPress}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onPress()
|
||||
}}
|
||||
className="mb-3 cursor-pointer rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="mb-3 flex-row flex items-center justify-between">
|
||||
<div className="flex-row flex items-center">
|
||||
<MyText className="text-lg font-bold text-gray-900">
|
||||
#{order.readableId}
|
||||
</MyText>
|
||||
{order.isFlashDelivery && (
|
||||
<span className="ml-2 rounded-full border border-amber-200 bg-amber-100 px-2 py-0.5">
|
||||
<MyText className="text-[10px] font-black uppercase text-amber-700">⚡</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`rounded-full border px-3 py-1 ${statusStyle}`}>
|
||||
<MyText className={`text-xs font-bold uppercase tracking-wider ${statusStyle.split(' ')[0]}`}>
|
||||
{order.status}
|
||||
</MyText>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-row flex items-center justify-between">
|
||||
<div>
|
||||
<MyText className="mb-1 text-sm text-gray-500">
|
||||
{dayjs(order.createdAt).format('MMM DD, YYYY • h:mm A')}
|
||||
</MyText>
|
||||
<MyText className="text-xs text-gray-400">
|
||||
{order.itemCount} {order.itemCount === 1 ? 'item' : 'items'}
|
||||
</MyText>
|
||||
</div>
|
||||
<MyText className="text-xl font-bold text-blue-600">
|
||||
₹{order.totalAmount}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex-row flex items-center justify-center border-t border-gray-100 pt-3">
|
||||
<MyText className="text-sm font-medium text-blue-600">
|
||||
View Order Details
|
||||
</MyText>
|
||||
<ChevronRight size={18} color="#3b82f6" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserDetails() {
|
||||
const navigate = useNavigate()
|
||||
const { id } = Route.useParams() as { id: string }
|
||||
const userId = id ? parseInt(id) : 0
|
||||
|
||||
const { data, isLoading, error, refetch } = trpc.admin.user.getUserDetails.useQuery(
|
||||
{ userId },
|
||||
{ enabled: !!userId }
|
||||
)
|
||||
|
||||
const updateSuspension = trpc.admin.user.updateUserSuspension.useMutation({
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const handleOrderPress = useCallback((orderId: number) => {
|
||||
navigate({ to: '/dashboard/orders/$id', params: { id: String(orderId) } })
|
||||
}, [navigate])
|
||||
|
||||
const handleSuspensionToggle = useCallback((isSuspended: boolean) => {
|
||||
updateSuspension.mutate({ userId, isSuspended })
|
||||
}, [userId, updateSuspension])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading user details...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<CircleAlert size={64} color="#ef4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
{error?.message || 'Failed to load user details'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const { user, orders } = data
|
||||
const displayName = user.name || 'Unnamed User'
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<div className="flex-row flex items-center border-b border-gray-200 bg-white px-4 py-4">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="-ml-4 p-2"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft size={24} color="#374151" />
|
||||
</button>
|
||||
<MyText className="ml-2 text-xl font-bold text-gray-900">User Details</MyText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="m-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex-row flex items-center">
|
||||
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
|
||||
<UserIcon size={24} color="#3B82F6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-row flex items-center">
|
||||
<MyText className="mb-0.5 text-lg font-bold text-gray-900">
|
||||
{user.mobile || 'No Mobile'}
|
||||
</MyText>
|
||||
{user.isSuspended && (
|
||||
<span className="ml-2 rounded-full border border-red-200 bg-red-100 px-2 py-0.5">
|
||||
<MyText className="text-[10px] font-bold uppercase text-red-700">Suspended</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MyText className="text-gray-500">
|
||||
{displayName}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-xl bg-gray-50 p-3">
|
||||
<div className="flex-row flex items-center">
|
||||
<Clock size={18} color="#6B7280" />
|
||||
<MyText className="ml-2 text-gray-600">
|
||||
Registered {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-row flex items-center justify-between border-t border-gray-100 pt-4">
|
||||
<div className="flex-1">
|
||||
<MyText className="mb-1 text-base font-bold text-gray-900">
|
||||
Suspend User
|
||||
</MyText>
|
||||
<MyText className="text-sm text-gray-500">
|
||||
Prevent user from placing orders
|
||||
</MyText>
|
||||
</div>
|
||||
{updateSuspension.isPending ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={user.isSuspended}
|
||||
onPress={() => handleSuspensionToggle(!user.isSuspended)}
|
||||
size={28}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserIncidentsView userId={userId} orderId={null} />
|
||||
|
||||
<div className="px-4 pb-8">
|
||||
<div className="mb-4 flex-row flex items-center justify-between">
|
||||
<MyText className="text-lg font-bold text-gray-900">Order History</MyText>
|
||||
<MyText className="text-sm text-gray-500">
|
||||
{orders.length} {orders.length === 1 ? 'order' : 'orders'}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="items-center rounded-xl border border-gray-100 bg-white p-8">
|
||||
<ShoppingBag size={48} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-gray-500">
|
||||
No orders yet
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<OrderItem
|
||||
key={order.id}
|
||||
order={order}
|
||||
onPress={() => handleOrderPress(order.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
265
apps/admin-web/src/routes/dashboard.users.tsx
Normal file
265
apps/admin-web/src/routes/dashboard.users.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import React, { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { ShoppingBag, CircleAlert, Users } from 'lucide-react'
|
||||
import { AppContainer, p as MyText, SearchBar, MyFlatList, MyButton } from 'web-components'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/users')({
|
||||
component: UserManagement,
|
||||
})
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
name: string | null
|
||||
mobile: string | null
|
||||
createdAt: string
|
||||
totalOrders: number
|
||||
lastOrderDate: string | null
|
||||
isSuspended: boolean
|
||||
}
|
||||
|
||||
interface UserItemProps {
|
||||
user: User
|
||||
index: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const UserItem: React.FC<UserItemProps> = ({ user, index, onClick }) => {
|
||||
const displayName = user.name || 'Unnamed User'
|
||||
const hasOrders = user.totalOrders > 0
|
||||
|
||||
const lastOrderText = user.lastOrderDate
|
||||
? formatDistanceToNow(new Date(user.lastOrderDate), { addSuffix: true })
|
||||
: 'Never'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onClick()
|
||||
}}
|
||||
className="mb-3 cursor-pointer rounded-xl border border-gray-100 bg-white p-4 text-left shadow-sm transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-row flex items-center justify-between">
|
||||
<div className="mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-gray-100">
|
||||
<MyText className="text-xs font-bold text-gray-600">{index + 1}</MyText>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-0.5 flex-row flex items-center">
|
||||
<MyText className="text-base font-bold text-gray-900">
|
||||
{user.mobile || 'No Mobile'}
|
||||
</MyText>
|
||||
{user.isSuspended && (
|
||||
<span className="ml-2 rounded-full border border-red-200 bg-red-100 px-2 py-0.5">
|
||||
<MyText className="text-[10px] font-bold uppercase text-red-700">Suspended</MyText>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MyText className="mb-1 text-sm text-gray-500">
|
||||
{displayName}
|
||||
</MyText>
|
||||
|
||||
<MyText className="text-xs text-gray-400">
|
||||
Registered: {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<div className="items-end">
|
||||
<div className="mb-1 flex-row flex items-center">
|
||||
<ShoppingBag size={14} color={hasOrders ? '#10B981' : '#9CA3AF'} />
|
||||
<MyText className={`ml-1 text-sm font-bold ${hasOrders ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
{user.totalOrders} orders
|
||||
</MyText>
|
||||
</div>
|
||||
|
||||
<MyText className="text-xs text-gray-400">
|
||||
Last: {lastOrderText}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListHeaderProps {
|
||||
searchTerm: string
|
||||
onSearchChange: (text: string) => void
|
||||
userCount: number
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const ListHeader: React.FC<ListHeaderProps> = ({ searchTerm, onSearchChange, userCount, refreshing, onRefresh }) => (
|
||||
<div>
|
||||
<div className="border-b border-gray-100 bg-white px-4 py-3">
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
onSearch={onSearchChange}
|
||||
placeholder="Search by mobile number..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-row flex items-center justify-between bg-gray-50 px-4 py-2">
|
||||
<MyText className="text-sm text-gray-500">
|
||||
Showing {userCount} users
|
||||
</MyText>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ListFooter: React.FC<{ isFetching: boolean, hasNextPage: boolean, onLoadMore: () => void }> = ({ isFetching, hasNextPage, onLoadMore }) => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="items-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (hasNextPage) {
|
||||
return (
|
||||
<div className="flex justify-center py-4">
|
||||
<MyButton textContent="Load more" onClick={onLoadMore} variant="blue" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function UserManagement() {
|
||||
const navigate = useNavigate()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch,
|
||||
} = trpc.admin.user.getAllUsers.useInfiniteQuery(
|
||||
{
|
||||
limit: 30,
|
||||
search: searchTerm || undefined,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.pages?.length) {
|
||||
setHasLoadedOnce(true)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const users = useMemo(() => {
|
||||
const allUsers = data?.pages.flatMap((page) => page.users) || []
|
||||
const uniqueUsers = allUsers.filter((user, index, self) =>
|
||||
index === self.findIndex((u) => u.id === user.id)
|
||||
)
|
||||
return uniqueUsers
|
||||
}, [data])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}, [refetch])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
const handleUserPress = useCallback((userId: number) => {
|
||||
navigate({ to: '/dashboard/users/$id', params: { id: String(userId) } })
|
||||
}, [navigate])
|
||||
|
||||
const renderUserItem = useCallback((item: User, index: number) => {
|
||||
return <UserItem user={item} index={index} onClick={() => handleUserPress(item.id)} />
|
||||
}, [handleUserPress])
|
||||
|
||||
if (isLoading && !hasLoadedOnce) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
<MyText className="mt-4 text-gray-500">Loading users...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
<CircleAlert size={64} color="#ef4444" />
|
||||
<MyText className="mt-4 text-lg font-bold text-gray-900">Error</MyText>
|
||||
<MyText className="mt-2 text-center text-gray-500">
|
||||
{error?.message || 'Failed to load users'}
|
||||
</MyText>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-6 rounded-full bg-blue-600 px-6 py-3"
|
||||
>
|
||||
<MyText className="font-semibold text-white">Retry</MyText>
|
||||
</button>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50">
|
||||
<MyFlatList
|
||||
data={users}
|
||||
renderItem={renderUserItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
ListHeaderComponent={
|
||||
<ListHeader
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
userCount={users.length}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<ListFooter isFetching={isFetchingNextPage} hasNextPage={!!hasNextPage} onLoadMore={handleLoadMore} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<div className="flex flex-1 items-center justify-center py-12">
|
||||
<Users size={64} color="#e5e7eb" />
|
||||
<MyText className="mt-4 text-center text-lg text-gray-500">
|
||||
No users found
|
||||
</MyText>
|
||||
{searchTerm && (
|
||||
<MyText className="mt-1 text-center text-gray-400">
|
||||
Try adjusting your search
|
||||
</MyText>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
421
apps/admin-web/src/routes/dashboard.vendor-snippets.tsx
Normal file
421
apps/admin-web/src/routes/dashboard.vendor-snippets.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText, div as MyTouchableOpacity } from 'web-components'
|
||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||
import { theme } from '@/lib/theme'
|
||||
import { trpc, getTrpcClient } from '@/lib/trpc-client'
|
||||
import { Fingerprint, CalendarCheck, ShoppingBag, Link2, ChevronRight, Plus, Code2 } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import VendorSnippetForm from '../components/VendorSnippetForm'
|
||||
import SnippetOrdersView from '../components/SnippetOrdersView'
|
||||
import { SnippetMenu } from '../components/SnippetMenu'
|
||||
import { ProductListDialog } from '../components/ProductListDialog'
|
||||
import type {
|
||||
VendorSnippet,
|
||||
VendorSnippetProduct,
|
||||
VendorSnippetForm as VendorSnippetFormType,
|
||||
} from '@/types/vendor-snippets'
|
||||
|
||||
export const Route = createFileRoute('/dashboard/vendor-snippets')({
|
||||
component: VendorSnippets,
|
||||
})
|
||||
|
||||
const SnippetItem = ({
|
||||
snippet,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOrders,
|
||||
onViewProducts,
|
||||
index,
|
||||
}: {
|
||||
snippet: VendorSnippet
|
||||
onEdit: (snippet: VendorSnippet) => void
|
||||
onDelete: (id: number) => void
|
||||
onViewOrders: (snippetCode: string) => void
|
||||
onViewProducts: (products: VendorSnippetProduct[]) => void
|
||||
index: number
|
||||
}) => {
|
||||
|
||||
const isExpired =
|
||||
snippet.validTill && dayjs(snippet.validTill).isBefore(dayjs())
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
if (!snippet.accessUrl) {
|
||||
window.alert('Error: No link available')
|
||||
return
|
||||
}
|
||||
if (typeof navigator.share === 'function') {
|
||||
await navigator.share({ text: snippet.accessUrl })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(snippet.accessUrl)
|
||||
window.alert('Link copied to clipboard')
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') return
|
||||
window.alert('Error: Failed to share link')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex-row flex items-start justify-between">
|
||||
<div className="flex-row flex flex-1 items-center">
|
||||
<div
|
||||
className="mr-4 flex h-12 w-12 items-center justify-center rounded-2xl"
|
||||
style={{ backgroundColor: theme.colors.brand50 }}
|
||||
>
|
||||
<Fingerprint
|
||||
size={24}
|
||||
color={theme.colors.brand600}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MyText
|
||||
className="text-[10px] font-black uppercase tracking-widest text-slate-400"
|
||||
>
|
||||
Identifier
|
||||
</MyText>
|
||||
<MyText
|
||||
className="text-xl font-black text-slate-900"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{snippet.snippetCode}
|
||||
</MyText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-row flex items-center">
|
||||
<div
|
||||
className="mr-2 flex-row flex items-center rounded-full px-3 py-1.5"
|
||||
style={{ backgroundColor: isExpired ? '#FFF1F2' : '#F0FDF4' }}
|
||||
>
|
||||
<span
|
||||
className="mr-2 h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: isExpired ? '#E11D48' : '#10B981' }}
|
||||
/>
|
||||
<MyText
|
||||
className="text-[10px] font-black uppercase tracking-tighter"
|
||||
style={{ color: isExpired ? '#E11D48' : '#10B981' }}
|
||||
>
|
||||
{isExpired ? 'Expired' : 'Active'}
|
||||
</MyText>
|
||||
</div>
|
||||
<SnippetMenu
|
||||
snippet={snippet}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOrders={onViewOrders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-6 flex-row flex items-center rounded-3xl border border-slate-100 bg-slate-50 p-4"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white shadow-sm"
|
||||
>
|
||||
<CalendarCheck size={20} color="#64748B" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
{snippet.isPermanent ? (
|
||||
<>
|
||||
<MyText className="text-sm font-extrabold text-slate-900">
|
||||
Permanent
|
||||
</MyText>
|
||||
<MyText className="text-[10px] font-bold uppercase text-slate-500">
|
||||
Always Active
|
||||
</MyText>
|
||||
</>
|
||||
) : snippet.slot?.deliveryTime ? (
|
||||
<>
|
||||
<MyText className="text-sm font-extrabold text-slate-900">
|
||||
{dayjs(snippet.slot.deliveryTime).format('ddd, MMM DD')}
|
||||
</MyText>
|
||||
<MyText className="text-[10px] font-bold uppercase text-slate-500">
|
||||
Time: {dayjs(snippet.slot.deliveryTime).format('hh:mm A')}
|
||||
</MyText>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MyText className="text-sm font-extrabold text-slate-900">
|
||||
Schedule Pending
|
||||
</MyText>
|
||||
<MyText className="text-[10px] font-bold uppercase text-slate-500">
|
||||
N/A
|
||||
</MyText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{snippet.validTill && (
|
||||
<div className="items-end">
|
||||
<MyText className="text-[9px] font-bold uppercase text-slate-400">
|
||||
Expires
|
||||
</MyText>
|
||||
<MyText
|
||||
className="text-xs font-black"
|
||||
style={{ color: isExpired ? '#E11D48' : '#64748B' }}
|
||||
>
|
||||
{dayjs(snippet.validTill).format('MMM DD')}
|
||||
</MyText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-row flex items-center justify-between">
|
||||
<div className="flex-row flex items-center">
|
||||
<MyTouchableOpacity
|
||||
onClick={() => onViewProducts(snippet.products)}
|
||||
className="mr-4 flex-row items-center"
|
||||
>
|
||||
<ShoppingBag size={14} color="#94A3B8" />
|
||||
<MyText className="ml-1.5 text-xs font-bold text-slate-500">
|
||||
{snippet.skuIds.length} Items
|
||||
</MyText>
|
||||
<ChevronRight size={14} color="#94A3B8" />
|
||||
</MyTouchableOpacity>
|
||||
<MyTouchableOpacity
|
||||
onClick={handleCopyLink}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<Link2
|
||||
size={16}
|
||||
color={theme.colors.brand500}
|
||||
/>
|
||||
<MyText
|
||||
className="ml-1 text-xs font-black"
|
||||
style={{ color: theme.colors.brand600 }}
|
||||
>
|
||||
Share
|
||||
</MyText>
|
||||
</MyTouchableOpacity>
|
||||
</div>
|
||||
|
||||
<MyTouchableOpacity
|
||||
onClick={() => {}}
|
||||
activeOpacity={0.7}
|
||||
className="flex-row items-center"
|
||||
>
|
||||
<MyText
|
||||
className="text-xs font-black uppercase tracking-widest text-slate-900"
|
||||
>
|
||||
View Slot
|
||||
</MyText>
|
||||
<ChevronRight size={16} color="#000" />
|
||||
</MyTouchableOpacity>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VendorSnippets() {
|
||||
const {
|
||||
data: snippets,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = trpc.admin.vendorSnippets.getAll.useQuery()
|
||||
|
||||
const createSnippet = trpc.admin.vendorSnippets.create.useMutation()
|
||||
const updateSnippet = trpc.admin.vendorSnippets.update.useMutation()
|
||||
const deleteSnippet = trpc.admin.vendorSnippets.delete.useMutation()
|
||||
|
||||
void createSnippet
|
||||
void updateSnippet
|
||||
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [editingSnippet, setEditingSnippet] =
|
||||
useState<VendorSnippetFormType | null>(null)
|
||||
const [showOrdersView, setShowOrdersView] = useState(false)
|
||||
const [ordersData, setOrdersData] = useState<any>(null)
|
||||
const [showProductList, setShowProductList] = useState(false)
|
||||
const [selectedProducts, setSelectedProducts] = useState<VendorSnippetProduct[] | null>(null)
|
||||
|
||||
useManualRefresh(() => {
|
||||
void refetch()
|
||||
})
|
||||
|
||||
useMarkDataFetchers(() => {
|
||||
refetch()
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
setShowCreateForm(true)
|
||||
setEditingSnippet(null)
|
||||
}
|
||||
|
||||
const handleViewProducts = (products: VendorSnippetProduct[]) => {
|
||||
setSelectedProducts(products)
|
||||
setShowProductList(true)
|
||||
}
|
||||
|
||||
const handleEdit = (snippet: VendorSnippet) => {
|
||||
const formSnippet: VendorSnippetFormType = {
|
||||
id: snippet.id,
|
||||
snippetCode: snippet.snippetCode,
|
||||
slotId: snippet.slotId || 0,
|
||||
isPermanent: snippet.isPermanent,
|
||||
skuIds: snippet.skuIds,
|
||||
validTill: snippet.validTill,
|
||||
createdAt: snippet.createdAt,
|
||||
}
|
||||
setEditingSnippet(formSnippet)
|
||||
setShowCreateForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
deleteSnippet.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleViewOrders = async (snippetCode: string) => {
|
||||
try {
|
||||
const trpcClient = getTrpcClient()
|
||||
const result =
|
||||
await trpcClient.admin.vendorSnippets.getOrdersBySnippet.query({
|
||||
snippetCode,
|
||||
})
|
||||
if (result.success) {
|
||||
setOrdersData({
|
||||
orders: result.data,
|
||||
snippetCode: snippetCode,
|
||||
})
|
||||
setShowOrdersView(true)
|
||||
} else {
|
||||
window.alert('Error: Failed to fetch orders')
|
||||
}
|
||||
} catch (error: any) {
|
||||
window.alert(`Error: ${error.message || 'Failed to fetch orders'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-gray-600">Loading vendor snippets...</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<MyText className="text-red-600">Error loading snippets</MyText>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (showOrdersView && ordersData) {
|
||||
return (
|
||||
<SnippetOrdersView
|
||||
orders={ordersData.orders}
|
||||
snippetCode={ordersData.snippetCode}
|
||||
onClose={() => {
|
||||
setShowOrdersView(false)
|
||||
setOrdersData(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCreateForm) {
|
||||
return (
|
||||
<VendorSnippetForm
|
||||
snippet={editingSnippet}
|
||||
onClose={() => {
|
||||
setShowCreateForm(false)
|
||||
setEditingSnippet(null)
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false)
|
||||
setEditingSnippet(null)
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
<div className="h-full flex-1 bg-white">
|
||||
<div
|
||||
className="flex-1 pb-32 pt-2"
|
||||
>
|
||||
{snippets && snippets.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center py-20">
|
||||
<div
|
||||
className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-slate-50"
|
||||
>
|
||||
<Code2 size={48} color="#94A3B8" />
|
||||
</div>
|
||||
<MyText
|
||||
className="text-xl font-black tracking-tight text-slate-900"
|
||||
>
|
||||
No Snippets Yet
|
||||
</MyText>
|
||||
<MyText
|
||||
className="mt-2 px-8 text-center font-medium text-slate-500"
|
||||
>
|
||||
Start by creating your first vendor identifier using the
|
||||
button below.
|
||||
</MyText>
|
||||
</div>
|
||||
) : (
|
||||
snippets?.map((snippet, index) => (
|
||||
<React.Fragment key={snippet.id}>
|
||||
<SnippetItem
|
||||
snippet={snippet}
|
||||
index={index}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onViewOrders={handleViewOrders}
|
||||
onViewProducts={handleViewProducts}
|
||||
/>
|
||||
{index < snippets.length - 1 && (
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AppContainer>
|
||||
<MyTouchableOpacity
|
||||
onClick={handleCreate}
|
||||
activeOpacity={0.95}
|
||||
className="fixed bottom-8 right-6 z-50 shadow-2xl"
|
||||
aria-label="Create vendor snippet"
|
||||
>
|
||||
<span
|
||||
className="flex h-16 w-16 items-center justify-center shadow-lg"
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
background: 'linear-gradient(135deg, #1570EF, #194185)',
|
||||
}}
|
||||
>
|
||||
<Plus size={32} color="white" />
|
||||
</span>
|
||||
</MyTouchableOpacity>
|
||||
<ProductListDialog
|
||||
open={showProductList}
|
||||
onClose={() => setShowProductList(false)}
|
||||
products={selectedProducts || []}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
26
apps/admin-web/src/routes/index.tsx
Normal file
26
apps/admin-web/src/routes/index.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
import { AppContainer } from 'web-components'
|
||||
import { useStaffAuth } from '../components/context/staff-auth-context'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Index,
|
||||
})
|
||||
|
||||
function Index() {
|
||||
const { isLoggedIn, isLoading } = useStaffAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" />
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to="/dashboard" />
|
||||
}
|
||||
return <Navigate to="/login" />
|
||||
}
|
||||
86
apps/admin-web/src/routes/login.tsx
Normal file
86
apps/admin-web/src/routes/login.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AppContainer, p as MyText, pInput as MyTextInput, MyButton } from 'web-components'
|
||||
import { useStaffAuth } from '../components/context/staff-auth-context'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
component: LoginScreen,
|
||||
})
|
||||
|
||||
function LoginScreen() {
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
const { login, isLoggingIn, loginError } = useStaffAuth()
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!name.trim() || !password.trim()) {
|
||||
// Same guard as admin-ui's Alert.alert("Error", ...)
|
||||
setLocalError('Please enter both name and password')
|
||||
return
|
||||
}
|
||||
setLocalError(null)
|
||||
|
||||
try {
|
||||
await login(name.trim(), password)
|
||||
} catch (error) {
|
||||
// Error is handled in the context
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="flex flex-1 flex-col justify-center px-5">
|
||||
<MyText
|
||||
className="mb-10 text-center text-2xl font-bold text-[#333]"
|
||||
>
|
||||
Admin Login
|
||||
</MyText>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Staff Name"
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="mb-5"
|
||||
data-testid="login-name-input"
|
||||
aria-label="login-name-input"
|
||||
/>
|
||||
|
||||
<MyTextInput
|
||||
topLabel="Password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="mb-8"
|
||||
data-testid="login-password-input"
|
||||
aria-label="login-password-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleLogin()
|
||||
}}
|
||||
/>
|
||||
|
||||
{(loginError || localError) && (
|
||||
<MyText className="mb-5 text-center text-sm text-red-600">
|
||||
{localError ?? loginError}
|
||||
</MyText>
|
||||
)}
|
||||
|
||||
<MyButton
|
||||
textContent={isLoggingIn ? 'Logging in...' : 'Login'}
|
||||
onClick={handleLogin}
|
||||
disabled={isLoggingIn}
|
||||
fullWidth
|
||||
className="mb-5"
|
||||
data-testid="login-button"
|
||||
aria-label="login-button"
|
||||
/>
|
||||
</div>
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
18
apps/admin-web/src/services/toaster.tsx
Normal file
18
apps/admin-web/src/services/toaster.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { toast } from 'sonner'
|
||||
|
||||
// Same API as admin-ui services/toaster.tsx, backed by sonner for web.
|
||||
export function InfoToast(message: string) {
|
||||
toast.info(message, { duration: 10000 })
|
||||
}
|
||||
|
||||
export function ErrorToast(message: string) {
|
||||
toast.error(message)
|
||||
}
|
||||
|
||||
export function SuccessToast(message: string) {
|
||||
toast.success(message)
|
||||
}
|
||||
|
||||
export function NotificationToast(title: string, subtitle: string, _data: any) {
|
||||
toast.info(title, { description: subtitle })
|
||||
}
|
||||
8
apps/admin-web/src/ssr.tsx
Normal file
8
apps/admin-web/src/ssr.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
createStartHandler,
|
||||
defaultStreamHandler,
|
||||
} from '@tanstack/react-start/server'
|
||||
|
||||
export default createStartHandler({
|
||||
handler: defaultStreamHandler,
|
||||
})
|
||||
150
apps/admin-web/src/styles.css
Normal file
150
apps/admin-web/src/styles.css
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-25: #EFF8FF;
|
||||
--color-brand-50: #F5FAFF;
|
||||
--color-brand-100: #D1E9FF;
|
||||
--color-brand-200: #B2DDFF;
|
||||
--color-brand-300: #84CAFF;
|
||||
--color-brand-400: #53B1FD;
|
||||
--color-brand-500: #2E90FA;
|
||||
--color-brand-600: #1570EF;
|
||||
--color-brand-700: #175CD3;
|
||||
--color-brand-800: #1849A9;
|
||||
--color-brand-900: #194185;
|
||||
/* Flash delivery accent — consistent with user-ui */
|
||||
--color-flash-25: #FFF5F6;
|
||||
--color-flash-50: #FFE8EA;
|
||||
--color-flash-100: #FFD1D6;
|
||||
--color-flash-200: #FFA3AE;
|
||||
--color-flash-300: #FF7585;
|
||||
--color-flash-400: #F81260;
|
||||
--color-flash-500: #E11D48;
|
||||
--color-flash-600: #C40E50;
|
||||
--color-flash-700: #9E0A3F;
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-display: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #f9fafb;
|
||||
color: #111827;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Safe area padding for mobile devices */
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Line clamp utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Focus rings — architecture, not decoration */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-600);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* App shell — full-height flex column for content; sidebar rail at md+.
|
||||
Sticky topbar/search live inside .shell-main so they can stick against
|
||||
the viewport while scrolling. */
|
||||
.shell-grid {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-areas: "main";
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.shell-grid {
|
||||
grid-template-areas: "sidebar main";
|
||||
grid-template-columns: 20vw minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.shell-sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
/* Flex column so sticky header elements are bounded by the full column,
|
||||
not a fixed grid row. */
|
||||
.shell-main {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-topbar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Typography scale — display */
|
||||
.display-1 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.display-2 {
|
||||
font-size: clamp(1.5rem, 2.5vw, 2rem);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Rule under section titles — flat, editorial */
|
||||
.section-rule {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Respect reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
6
apps/admin-web/src/types/drag-order-item.ts
Normal file
6
apps/admin-web/src/types/drag-order-item.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Common props for draggable reorder rows (customize-app / product-tags order screens)
|
||||
export interface DragOrderItemProps<T> {
|
||||
item: T
|
||||
drag: () => void
|
||||
isActive: boolean
|
||||
}
|
||||
32
apps/admin-web/src/types/vendor-snippets.ts
Normal file
32
apps/admin-web/src/types/vendor-snippets.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { IdName } from '@packages/shared';
|
||||
import type { AdminVendorSnippetInput } from '@packages/shared'
|
||||
|
||||
export type VendorSnippetProduct = IdName;
|
||||
|
||||
export interface VendorSnippet {
|
||||
id: number;
|
||||
snippetCode: string;
|
||||
slotId: number | null;
|
||||
isPermanent: boolean;
|
||||
skuIds: number[];
|
||||
products: VendorSnippetProduct[];
|
||||
validTill: string | null;
|
||||
createdAt: string;
|
||||
accessUrl?: string;
|
||||
slot?: {
|
||||
id: number;
|
||||
deliveryTime: string;
|
||||
freezeTime: string;
|
||||
isActive: boolean;
|
||||
deliverySequence?: unknown;
|
||||
isFlash?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Vendor-snippet form row — anchored to the shared input (wire validTill);
|
||||
// form requires slotId and carries the serialized createdAt.
|
||||
export type VendorSnippetForm = Omit<AdminVendorSnippetInput, 'slotId'> & {
|
||||
id: number;
|
||||
slotId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
33
apps/admin-web/tsconfig.json
Normal file
33
apps/admin-web/tsconfig.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
"@/*": ["./src/*", "../../apps/backend/*"],
|
||||
"web-components": ["../../packages/web-components/src"],
|
||||
"web-components/*": ["../../packages/web-components/src/*"],
|
||||
"@packages/shared": ["../../packages/shared"],
|
||||
"@packages/shared/*": ["../../packages/shared/*"],
|
||||
"@backend/*": ["../../apps/backend/src/*"]
|
||||
},
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
}
|
||||
}
|
||||
26
apps/admin-web/vite.config.ts
Normal file
26
apps/admin-web/vite.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { nitro } from 'nitro/vite'
|
||||
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
server: { port: 4174 },
|
||||
plugins: [
|
||||
devtools(),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
cloudflare({
|
||||
viteEnvironment: {
|
||||
name: "ssr"
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
12
apps/admin-web/wrangler.jsonc
Normal file
12
apps/admin-web/wrangler.jsonc
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "admin-web",
|
||||
"compatibility_date": "2026-05-10",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"main": "@tanstack/react-start/server-entry",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod';
|
||||
import { generateSignedUrlsFromS3Urls } from '@/src/lib/s3-client'
|
||||
import { scaffoldAssetUrl } from '@/src/lib/s3-client'
|
||||
import { queueDataPusher } from '@/src/lib/queue-data-pusher'
|
||||
import {
|
||||
getComplaintById,
|
||||
|
|
@ -68,29 +68,25 @@ export const complaintRouter = router({
|
|||
|
||||
const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData;
|
||||
|
||||
const complaintsWithSignedImages = await Promise.all(
|
||||
complaintsToReturn.map(async (c: ComplaintWithUser) => {
|
||||
const signedImages = c.images
|
||||
? await generateSignedUrlsFromS3Urls(c.images as string[])
|
||||
: [];
|
||||
const complaintsWithImageUrls = complaintsToReturn.map((c: ComplaintWithUser) => {
|
||||
const images = scaffoldAssetUrl((c.images as string[]) || [])
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
text: c.complaintBody,
|
||||
userId: c.userId,
|
||||
userName: c.userName,
|
||||
userMobile: c.userMobile,
|
||||
orderId: c.orderId,
|
||||
status: c.isResolved ? 'resolved' : 'pending',
|
||||
createdAt: c.createdAt,
|
||||
images: signedImages,
|
||||
response: c.response,
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
id: c.id,
|
||||
text: c.complaintBody,
|
||||
userId: c.userId,
|
||||
userName: c.userName,
|
||||
userMobile: c.userMobile,
|
||||
orderId: c.orderId,
|
||||
status: c.isResolved ? 'resolved' : 'pending',
|
||||
createdAt: c.createdAt,
|
||||
images,
|
||||
response: c.response,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
complaints: complaintsWithSignedImages,
|
||||
complaints: complaintsWithImageUrls,
|
||||
nextCursor: hasMore
|
||||
? complaintsToReturn[complaintsToReturn.length - 1].id
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { router, protectedProcedure } from '@/src/trpc/trpc-index'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/src/lib/api-error'
|
||||
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
|
||||
import { scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
|
||||
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
|
||||
import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache'
|
||||
import {
|
||||
|
|
@ -65,21 +65,17 @@ export const productRouter = router({
|
|||
});
|
||||
*/
|
||||
|
||||
const productsWithSignedUrls = await Promise.all(
|
||||
products.map(async (product) => ({
|
||||
...product,
|
||||
skus: await Promise.all(
|
||||
product.skus.map(async (sku) => ({
|
||||
...sku,
|
||||
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||
}))
|
||||
),
|
||||
}))
|
||||
)
|
||||
const productsWithImageUrls = products.map((product) => ({
|
||||
...product,
|
||||
skus: product.skus.map((sku) => ({
|
||||
...sku,
|
||||
images: scaffoldAssetUrl((sku.images as string[]) || []),
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
products: productsWithSignedUrls,
|
||||
count: productsWithSignedUrls.length,
|
||||
products: productsWithImageUrls,
|
||||
count: productsWithImageUrls.length,
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -136,18 +132,16 @@ export const productRouter = router({
|
|||
throw new ApiError('Product not found', 404)
|
||||
}
|
||||
|
||||
const productWithSignedUrls = {
|
||||
const productWithImageUrls = {
|
||||
...product,
|
||||
skus: await Promise.all(
|
||||
product.skus.map(async (sku) => ({
|
||||
...sku,
|
||||
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||
}))
|
||||
),
|
||||
skus: product.skus.map((sku) => ({
|
||||
...sku,
|
||||
images: scaffoldAssetUrl((sku.images as string[]) || []),
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
product: productWithSignedUrls,
|
||||
product: productWithImageUrls,
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
@ -258,18 +252,16 @@ export const productRouter = router({
|
|||
|
||||
await scheduleStoreInitialization()
|
||||
|
||||
const productWithSignedUrls = {
|
||||
const productWithImageUrls = {
|
||||
...newProduct,
|
||||
skus: await Promise.all(
|
||||
newProduct.skus.map(async (sku) => ({
|
||||
...sku,
|
||||
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||
}))
|
||||
),
|
||||
skus: newProduct.skus.map((sku) => ({
|
||||
...sku,
|
||||
images: scaffoldAssetUrl((sku.images as string[]) || []),
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
product: productWithSignedUrls,
|
||||
product: productWithImageUrls,
|
||||
message: 'Product created successfully',
|
||||
}
|
||||
}),
|
||||
|
|
@ -356,18 +348,16 @@ export const productRouter = router({
|
|||
|
||||
await scheduleStoreInitialization()
|
||||
|
||||
const productWithSignedUrls = {
|
||||
const productWithImageUrls = {
|
||||
...updatedProduct,
|
||||
skus: await Promise.all(
|
||||
updatedProduct.skus.map(async (sku) => ({
|
||||
...sku,
|
||||
images: await generateSignedUrlsFromS3Urls((sku.images as string[]) || []),
|
||||
}))
|
||||
),
|
||||
skus: updatedProduct.skus.map((sku) => ({
|
||||
...sku,
|
||||
images: scaffoldAssetUrl((sku.images as string[]) || []),
|
||||
})),
|
||||
}
|
||||
|
||||
return {
|
||||
product: productWithSignedUrls,
|
||||
product: productWithImageUrls,
|
||||
message: 'Product updated successfully',
|
||||
}
|
||||
}),
|
||||
|
|
@ -544,17 +534,15 @@ export const productRouter = router({
|
|||
return { reviews: reviewsWithSignedUrls, hasMore };
|
||||
*/
|
||||
|
||||
const reviewsWithSignedUrls = await Promise.all(
|
||||
reviews.map(async (review) => ({
|
||||
...review,
|
||||
signedImageUrls: await generateSignedUrlsFromS3Urls((review.imageUrls as string[]) || []),
|
||||
signedAdminImageUrls: await generateSignedUrlsFromS3Urls((review.adminResponseImages as string[]) || []),
|
||||
}))
|
||||
)
|
||||
const reviewsWithImageUrls = reviews.map((review) => ({
|
||||
...review,
|
||||
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
|
||||
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
|
||||
}))
|
||||
|
||||
const hasMore = offset + limit < totalCount
|
||||
|
||||
return { reviews: reviewsWithSignedUrls, hasMore }
|
||||
return { reviews: reviewsWithImageUrls, hasMore }
|
||||
}),
|
||||
|
||||
respondToReview: protectedProcedure
|
||||
|
|
@ -852,15 +840,13 @@ export const productRouter = router({
|
|||
.query(async (): Promise<{ tags: Array<{ id: number; tagName: string; tagDescription: string | null; imageUrl: string | null; isDashboardTag: boolean; relatedStores: number[] | null; createdAt: Date }>; message: string }> => {
|
||||
const tags = await getAllProductTagInfosInDb()
|
||||
|
||||
const tagsWithSignedUrls = await Promise.all(
|
||||
tags.map(async (tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
}))
|
||||
)
|
||||
const tagsWithImageUrls = tags.map((tag) => ({
|
||||
...tag,
|
||||
imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null,
|
||||
}))
|
||||
|
||||
return {
|
||||
tags: tagsWithSignedUrls,
|
||||
tags: tagsWithImageUrls,
|
||||
message: 'Tags retrieved successfully',
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1441,3 +1441,28 @@ apps/admin-ui/src/components/ProductForm.tsx (onSubmit normalization — single
|
|||
+ marketPrice: v.marketPrice == null || v.marketPrice === '' ? null : Number(v.marketPrice)
|
||||
+ flashPrice: v.flashPrice == null || v.flashPrice === '' ? null : Number(v.flashPrice)
|
||||
(features normalization unchanged). Semantics preserved: cleared price → undefined → yup required() blocks submit; cleared market/flash → null/undefined as the old parseFloat-conditional did. add.tsx pre-check (price == null || price <= 0) and edit.tsx (marketPrice ?? undefined) work unchanged — values are genuinely numeric past this point.
|
||||
|
||||
[2026-09-06 00:00:00] Scaffold apps/admin-web as copy of apps/web-ui (src, public, package.json, package-lock.json, vite.config.ts, tsconfig.json, wrangler.jsonc, README.md). Excluded: node_modules, dist, .output, .tanstack, .wrangler.
|
||||
Planned edits: package.json name web-ui->admin-web + add sonner, @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, date-fns (web equivalents of react-native-toast-message, react-native-draggable-flatlist, date-fns already used by admin-ui); vite.config.ts port 4174->4175; wrangler.jsonc worker name; root package.json workspaces + !"apps/admin-web" + admin-web scripts (mirror web-ui standalone treatment).
|
||||
|
||||
[2026-09-06 00:05:00] admin-web infra batch 1 (new files): src/lib/event-bus.ts (DeviceEventEmitter-compatible bus + REFRESH_EVENT/FORCE_LOGOUT_EVENT consts), src/lib/trpc-client.ts (admin: BASE_API_URL https://devapi.freshyo.in fallback + VITE_API_URL override, Bearer JWT, 401 UNAUTHORIZED -> force-logout emit), src/lib/query-client.ts (singleton, retry 3 — same as admin-ui), src/lib/refresh-context.tsx (RefreshProvider/useRefresh/useManualRefresh/useMarkDataFetchers/useFocusCallback web ports), src/lib/theme.ts (exact theme.colors hex copy from packages/ui), src/lib/navigation-target.ts (useNavigationTarget zustand copy), src/hooks/useJWT.ts, src/hooks/useUploadToObjectStorage.ts (trpc path fix only), src/services/toaster.tsx (sonner-backed same 4-function API), src/types/vendor-snippets.ts + drag-order-item.ts (verbatim copies), src/components/context/staff-auth-context.tsx (expo-router/DeviceEventEmitter -> TanStack/bus). Deleted copied user-app src/routes/*, src/components/*, src/hooks/*, src/lib/* (scaffold placeholders, replaced by admin ports).
|
||||
|
||||
[2026-09-06 00:30:00] admin-web dashboard heading fixes (3 edits): (1) dashboard.index.tsx MenuItemComponent text wrapper span flex-1 -> flex min-w-0 flex-1 flex-col (title/subtitle stacked vertically like mobile RN column layout; was rendering inline same-line); (2) dashboard.index.tsx quick-action title add numberOfLines={2} (parity with mobile index.tsx:257); (3) dashboard.products.$id.tsx reply-quote raw <p> -> <MyText numberOfLines={3}> (parity with mobile detail/[id].tsx:480 N=3, same classes).
|
||||
|
||||
[2026-09-06 00:32:00] VERIFIED dashboard heading fixes: tsc 0 errors, vite build clean.
|
||||
|
||||
[2026-09-06 00:35:00] admin-web dashboard quick-action tiles: button className add flex flex-col (was items-center with no display:flex, so icon left-aligned; mobile tile is column + items-center with icon centered above title). Title already text-center.
|
||||
|
||||
[2026-09-06 00:36:00] VERIFIED quick-action tile fix: tsc 0 errors, vite build clean.
|
||||
|
||||
[2026-09-06 00:40:00] admin-web dashboard quick-action tiles: replace style width calc(25%-9px) with flex-1. RN ignores calc() so mobile tiles are content-width filling the row; web honored calc giving a 4-per-row grid with an empty 4th slot (~30% gap). flex-1 gives 3 equal tiles filling the row on all viewports, matching the phone look.
|
||||
|
||||
[2026-09-06 00:41:00] VERIFIED quick-action full-width fix: tsc 0 errors, vite build clean.
|
||||
|
||||
[2026-09-06 00:45:00] admin-web FABs absolute->fixed (viewport-pinned bottom-right irrespective of scroll; offsets/z-index unchanged): dashboard.banners.tsx:424, dashboard.customize-app.popular.tsx:338, dashboard.stores.tsx:187, dashboard.slots.tsx:286, dashboard.slots.detail.tsx:204, dashboard.coupons.tsx:502, dashboard.product-groupings.tsx:190, dashboard.rebalance.tsx (style position), dashboard.orders.sequence.tsx:683 save FAB (kept bottom-LEFT per mobile original delivery-sequences:680, only pinned to viewport).
|
||||
|
||||
[2026-09-06 00:47:00] VERIFIED FAB viewport pinning: tsc 0 errors, vite build clean.
|
||||
|
||||
[2026-09-06 01:00:00] backend admin-apis image-URL pattern: replicate user-ui scaffoldAssetUrl in admin-apis/product.ts (9 sites) + admin-apis/complaint.ts (1 site), replacing generateSignedUrlsFromS3Urls. Collapse redundant await/Promise.all wrappers, drop unused import, rename signed* locals. Response field names unchanged (no frontend impact). Write/upload paths untouched. No migration.
|
||||
|
||||
[2026-09-06 01:05:00] VERIFIED admin image-URL unification: backend tsc 0 errors (= baseline); only remaining generateSignedUrlsFromS3Urls refs in admin-apis are inside commented-out blocks (product.ts old getProductById + old reviews query), left untouched as history. Live admin product/complaint image fields now return permanent CDN scaffoldAssetUrl URLs like user-apis; response field names unchanged.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
"web-ui": "bun run --filter web-ui",
|
||||
"web-ui:dev": "bun run web-ui dev",
|
||||
"web-ui:build": "bun run web-ui build",
|
||||
"admin-web:dev": "npm run dev --prefix apps/admin-web",
|
||||
"admin-web:build": "npm run build --prefix apps/admin-web",
|
||||
"docker:build": "docker buildx build --platform linux/amd64 -t mohdshafiuddin54/meat_farmer .",
|
||||
"docker:push": "docker push mohdshafiuddin54/meat_farmer",
|
||||
"docker:deploy": "docker buildx build --platform linux/amd64 -t mohdshafiuddin54/meat_farmer . && docker push mohdshafiuddin54/meat_farmer",
|
||||
|
|
@ -21,6 +23,7 @@
|
|||
"workspaces": [
|
||||
"apps/*",
|
||||
"!apps/web-ui",
|
||||
"!apps/admin-web",
|
||||
"packages/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue