Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
25cfdd5538 | |
|
|
53e2f33064 |
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import NotificationService from '#services/notification_service'
|
||||||
|
import type { NotificationType } from '#models/notification'
|
||||||
|
|
||||||
|
export default class NotificationsController {
|
||||||
|
/** GET /api/notifications?type=&isRead=&page=&perPage= */
|
||||||
|
async index({ request }: HttpContext) {
|
||||||
|
const page = Number(request.input('page', 1))
|
||||||
|
const perPage = Number(request.input('perPage', 25))
|
||||||
|
const type = request.input('type') as NotificationType | undefined
|
||||||
|
|
||||||
|
// isRead: 'true' | 'false' | undefined (không lọc)
|
||||||
|
const rawRead = request.input('isRead')
|
||||||
|
const isRead = rawRead === undefined ? undefined : rawRead === 'true' || rawRead === true
|
||||||
|
|
||||||
|
return NotificationService.list({ page, perPage, type, isRead })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/notifications/unread-count */
|
||||||
|
async unreadCount() {
|
||||||
|
return { count: await NotificationService.unreadCount() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/notifications/:id/read */
|
||||||
|
async markRead({ params }: HttpContext) {
|
||||||
|
return NotificationService.markRead(Number(params.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/notifications/read-all?type= */
|
||||||
|
async markAllRead({ request }: HttpContext) {
|
||||||
|
const type = request.input('type') as NotificationType | undefined
|
||||||
|
const updated = await NotificationService.markAllRead(type)
|
||||||
|
return { updated }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/notifications/:id */
|
||||||
|
async destroy({ params, response }: HttpContext) {
|
||||||
|
await NotificationService.destroy(Number(params.id))
|
||||||
|
return response.noContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
createProductValidator,
|
createProductValidator,
|
||||||
updateProductValidator,
|
updateProductValidator,
|
||||||
listProductValidator,
|
listProductValidator,
|
||||||
|
setNoListingValidator,
|
||||||
} from '#validators/product'
|
} from '#validators/product'
|
||||||
|
|
||||||
export default class ProductsController {
|
export default class ProductsController {
|
||||||
|
|
@ -13,14 +14,15 @@ export default class ProductsController {
|
||||||
const params = await request.validateUsing(listProductValidator)
|
const params = await request.validateUsing(listProductValidator)
|
||||||
const page = params.page ?? 1
|
const page = params.page ?? 1
|
||||||
const perPage = params.perPage ?? 25
|
const perPage = params.perPage ?? 25
|
||||||
const order = params.order ?? 'created_at'
|
const order = params.order ?? 'id'
|
||||||
const direction: 'asc' | 'desc' = params.direction === 'asc' ? 'asc' : 'desc'
|
const direction: 'asc' | 'desc' = params.direction === 'desc' ? 'desc' : 'asc'
|
||||||
|
|
||||||
const query = Product.query()
|
const query = Product.query()
|
||||||
|
|
||||||
if (params.condition) query.where('condition', params.condition)
|
if (params.condition) query.where('condition', params.condition)
|
||||||
if (params.type) query.where('type', params.type)
|
if (params.type) query.where('type', params.type)
|
||||||
if (params.warehouse) query.where('warehouse', params.warehouse)
|
if (params.warehouse) query.where('warehouse', params.warehouse)
|
||||||
|
if (params.noListing !== undefined) query.where('no_listing', params.noListing)
|
||||||
if (params.sku) {
|
if (params.sku) {
|
||||||
// FULLTEXT khi có, fallback LIKE
|
// FULLTEXT khi có, fallback LIKE
|
||||||
query.where((b) => {
|
query.where((b) => {
|
||||||
|
|
@ -49,6 +51,16 @@ export default class ProductsController {
|
||||||
return ProductService.update(params.id, data, auth.getUserOrFail().username)
|
return ProductService.update(params.id, data, auth.getUserOrFail().username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/products/no-listing
|
||||||
|
* body: { ids: number[], noListing: boolean }
|
||||||
|
* Gắn / bỏ cờ noListing hàng loạt (bỏ qua khi gợi ý giá).
|
||||||
|
*/
|
||||||
|
async setNoListing({ request, auth }: HttpContext) {
|
||||||
|
const { ids, noListing } = await request.validateUsing(setNoListingValidator)
|
||||||
|
return ProductService.setNoListing(ids, noListing, auth.getUserOrFail().username)
|
||||||
|
}
|
||||||
|
|
||||||
/** DELETE /api/products/:id */
|
/** DELETE /api/products/:id */
|
||||||
async destroy({ params, response, auth }: HttpContext) {
|
async destroy({ params, response, auth }: HttpContext) {
|
||||||
await ProductService.destroy(params.id, auth.getUserOrFail().username)
|
await ProductService.destroy(params.id, auth.getUserOrFail().username)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
|
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||||
|
|
||||||
|
/** Loại thông báo hiển thị cho người dùng. */
|
||||||
|
export type NotificationType = 'warning' | 'error' | 'success' | 'news'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thông báo hệ thống cho người dùng (giống bảng logs nhưng hướng người dùng):
|
||||||
|
* sản phẩm mới khi sync, cảnh báo import trùng, kết quả thao tác...
|
||||||
|
* Có trạng thái đã đọc / chưa đọc.
|
||||||
|
*/
|
||||||
|
export default class Notification extends BaseModel {
|
||||||
|
@column({ isPrimary: true })
|
||||||
|
declare id: number
|
||||||
|
|
||||||
|
/** warning | error | success | news */
|
||||||
|
@column()
|
||||||
|
declare type: NotificationType
|
||||||
|
|
||||||
|
@column()
|
||||||
|
declare title: string
|
||||||
|
|
||||||
|
@column()
|
||||||
|
declare message: string | null
|
||||||
|
|
||||||
|
/** false = chưa đọc (unread), true = đã đọc. */
|
||||||
|
@column()
|
||||||
|
declare isRead: boolean
|
||||||
|
|
||||||
|
/** Dữ liệu kèm theo (vd { productId, sku }) để frontend điều hướng / hành động. */
|
||||||
|
@column({
|
||||||
|
prepare: (v) => (v === null || v === undefined ? null : JSON.stringify(v)),
|
||||||
|
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
|
||||||
|
})
|
||||||
|
declare meta: Record<string, any> | null
|
||||||
|
|
||||||
|
@column.dateTime({ autoCreate: true })
|
||||||
|
declare createdAt: DateTime
|
||||||
|
|
||||||
|
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||||
|
declare updatedAt: DateTime
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,10 @@ export default class Product extends BaseModel {
|
||||||
@column()
|
@column()
|
||||||
declare warehouse: string | null
|
declare warehouse: string | null
|
||||||
|
|
||||||
|
/** true = sản phẩm rác / không cần gợi ý giá (bỏ qua khi chạy gợi ý giá). */
|
||||||
|
@column()
|
||||||
|
declare noListing: boolean
|
||||||
|
|
||||||
/** true nếu sản phẩm được sync từ ERP. */
|
/** true nếu sản phẩm được sync từ ERP. */
|
||||||
get isSynced() {
|
get isSynced() {
|
||||||
return this.erpId !== null && this.erpId !== undefined
|
return this.erpId !== null && this.erpId !== undefined
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,29 @@ const median = (arr: number[]) => {
|
||||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
|
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Số sản phẩm gộp trong 1 request GPT (batch). */
|
||||||
|
export const AI_BATCH_CHUNK_SIZE = 25
|
||||||
|
/** Số điểm giá tối đa (mới nhất) gửi kèm mỗi nguồn để tiết kiệm token. */
|
||||||
|
const MAX_POINTS_PER_SOURCE = 25
|
||||||
|
|
||||||
|
/** Chạy `fn` trên `items` với concurrency giới hạn, giữ nguyên thứ tự kết quả. */
|
||||||
|
export async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length)
|
||||||
|
let cursor = 0
|
||||||
|
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const index = cursor++
|
||||||
|
results[index] = await fn(items[index], index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(workers)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based
|
* Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based
|
||||||
* (port từ heuristic của prototype cũ).
|
* (port từ heuristic của prototype cũ).
|
||||||
|
|
@ -39,6 +62,38 @@ export default class AiService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gợi ý giá cho NHIỀU sản phẩm cùng lúc: gộp mỗi lô `AI_BATCH_CHUNK_SIZE`
|
||||||
|
* sản phẩm vào 1 request GPT (giảm số round-trip từ N -> N/chunk), các lô
|
||||||
|
* chạy song song có giới hạn. Trả về mảng cùng thứ tự với `inputs`.
|
||||||
|
*
|
||||||
|
* Item nào không có trong phản hồi AI (hoặc cả lô lỗi) sẽ tự động fallback
|
||||||
|
* rule-based, nên kết quả luôn đủ độ dài & không rớt sản phẩm nào.
|
||||||
|
*/
|
||||||
|
static async suggestBatch(inputs: SuggestionInput[]): Promise<Suggestion[]> {
|
||||||
|
if (!inputs.length) return []
|
||||||
|
|
||||||
|
const chunks: Array<{ start: number; items: SuggestionInput[] }> = []
|
||||||
|
for (let i = 0; i < inputs.length; i += AI_BATCH_CHUNK_SIZE) {
|
||||||
|
chunks.push({ start: i, items: inputs.slice(i, i + AI_BATCH_CHUNK_SIZE) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = new Array<Suggestion>(inputs.length)
|
||||||
|
await mapWithConcurrency(chunks, 5, async (chunk) => {
|
||||||
|
let byId: Map<number, Suggestion>
|
||||||
|
try {
|
||||||
|
byId = await this.openAiBatch(chunk.items)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, 'OpenAI batch suggest lỗi — fallback rule-based cả lô')
|
||||||
|
byId = new Map()
|
||||||
|
}
|
||||||
|
chunk.items.forEach((input, idx) => {
|
||||||
|
results[chunk.start + idx] = byId.get(input.product.id) ?? this.ruleBased(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
// --- Rule-based ---
|
// --- Rule-based ---
|
||||||
private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion {
|
private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion {
|
||||||
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
||||||
|
|
@ -103,25 +158,82 @@ export default class AiService {
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise<Suggestion> {
|
/**
|
||||||
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
* System prompt cho engine AI ở chế độ BATCH: định giá cho một mảng sản phẩm
|
||||||
const payload = {
|
* trong cùng một request. Giữ nguyên quy tắc định giá như bản single, chỉ khác
|
||||||
|
* ở chỗ đầu vào là mảng `items` và đầu ra là mảng `results` map theo `id`.
|
||||||
|
*/
|
||||||
|
static buildBatchSystemPrompt(floorMarkup: number): string {
|
||||||
|
return [
|
||||||
|
'Bạn là chuyên gia định giá listing trên eBay (thị trường Úc/Mỹ).',
|
||||||
|
'Nhiệm vụ: với MỖI sản phẩm trong mảng "items" (mỗi phần tử có "id", chi phí supplier,',
|
||||||
|
'và dữ liệu giá eBay đã bán "ebaySold" / đang bán "ebaySale"), đề xuất MỘT mức giá listing',
|
||||||
|
'tối ưu bằng USD: cạnh tranh để dễ bán nhưng vẫn đảm bảo biên lợi nhuận.',
|
||||||
|
'',
|
||||||
|
'Quy tắc định giá (áp dụng ĐỘC LẬP cho từng sản phẩm):',
|
||||||
|
'1. Ưu tiên neo giá theo median giá ĐÃ BÁN (ebaySold); nếu thiếu, dùng median giá ĐANG BÁN (ebaySale);',
|
||||||
|
' nếu vẫn thiếu, suy ra từ chi phí supplier với biên hợp lý (~1.6x).',
|
||||||
|
'2. Khử nhiễu: loại bỏ các điểm giá quá cao hoặc quá thấp bất thường so với median trước khi tính.',
|
||||||
|
`3. Sàn giá tuyệt đối = chi phí supplier (USD) * ${floorMarkup}. Giá đề xuất KHÔNG được thấp hơn sàn này.`,
|
||||||
|
'4. Nhỉnh dưới median thị trường một chút để tăng khả năng bán.',
|
||||||
|
'5. priceRange là khoảng dao động hợp lý quanh giá đề xuất (min < suggestedPrice < max).',
|
||||||
|
'',
|
||||||
|
'BẮT BUỘC trả về đủ MỘT kết quả cho MỖI id trong "items", giữ nguyên "id" tương ứng.',
|
||||||
|
'Chỉ trả về DUY NHẤT một object JSON hợp lệ (không markdown, không text ngoài JSON) theo schema:',
|
||||||
|
'{',
|
||||||
|
' "results": [',
|
||||||
|
' {',
|
||||||
|
' "id": number, // đúng id của sản phẩm trong items',
|
||||||
|
' "suggestedPrice": number, // giá đề xuất (USD), > 0',
|
||||||
|
' "priceRange": { "min": number, "max": number },',
|
||||||
|
' "reasoning": string // giải thích ngắn gọn bằng tiếng Việt',
|
||||||
|
' }',
|
||||||
|
' ]',
|
||||||
|
'}',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rút gọn một mảng điểm giá về đúng field cần cho định giá: `{ price, date }`.
|
||||||
|
* Bỏ các field thừa (title, itemId, source...), loại giá không hợp lệ,
|
||||||
|
* ưu tiên các điểm mới nhất và giới hạn số lượng để tiết kiệm token.
|
||||||
|
*/
|
||||||
|
private static compactPoints(points?: Array<Record<string, any>>): Array<{ price: number; date?: string }> {
|
||||||
|
if (!Array.isArray(points)) return []
|
||||||
|
return points
|
||||||
|
.map((p) => ({
|
||||||
|
price: Number(p?.price),
|
||||||
|
date: typeof p?.date === 'string' ? p.date : undefined,
|
||||||
|
}))
|
||||||
|
.filter((p) => Number.isFinite(p.price) && p.price > 0)
|
||||||
|
.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
|
||||||
|
.slice(0, MAX_POINTS_PER_SOURCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload gọn cho 1 sản phẩm (dùng chung cho cả single & batch). */
|
||||||
|
private static buildItemPayload({ product, dataSources, dataEbay }: SuggestionInput) {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
sku: product.sku,
|
sku: product.sku,
|
||||||
condition: product.condition,
|
condition: product.condition,
|
||||||
cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null,
|
cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null,
|
||||||
supplier: dataSources,
|
supplier: this.compactPoints(dataSources),
|
||||||
ebaySold: dataEbay.sold,
|
ebaySold: this.compactPoints(dataEbay?.sold),
|
||||||
ebaySale: dataEbay.sale,
|
ebaySale: this.compactPoints(dataEbay?.sale),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gọi ERP proxy -> GPT, trả về data thô để normalize. */
|
||||||
|
private static async callGpt(systemPrompt: string, userPayload: unknown): Promise<any> {
|
||||||
const gptPayload = {
|
const gptPayload = {
|
||||||
model: process.env.OPENAI_MODEL,
|
model: process.env.OPENAI_MODEL,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: this.buildSystemPrompt(floorMarkup) },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: JSON.stringify(payload) },
|
{ role: 'user', content: JSON.stringify(userPayload) },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const externalApiUrl = process.env.ERP_API_URL;
|
const externalApiUrl = process.env.ERP_API_URL
|
||||||
|
|
||||||
const remoteResp = await axios.post(
|
const remoteResp = await axios.post(
|
||||||
externalApiUrl + '/api/transferPostData',
|
externalApiUrl + '/api/transferPostData',
|
||||||
|
|
@ -138,7 +250,59 @@ export default class AiService {
|
||||||
if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') {
|
if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') {
|
||||||
throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data))
|
throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data))
|
||||||
}
|
}
|
||||||
return this.normalize(remoteResp.data?.data)
|
return remoteResp.data?.data
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async openAi(input: SuggestionInput): Promise<Suggestion> {
|
||||||
|
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
||||||
|
const { id, ...payload } = this.buildItemPayload(input)
|
||||||
|
const data = await this.callGpt(this.buildSystemPrompt(floorMarkup), payload)
|
||||||
|
return this.normalize(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gọi GPT cho 1 lô sản phẩm, trả về map productId -> Suggestion. */
|
||||||
|
private static async openAiBatch(inputs: SuggestionInput[]): Promise<Map<number, Suggestion>> {
|
||||||
|
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
||||||
|
const payload = { items: inputs.map((input) => this.buildItemPayload(input)) }
|
||||||
|
const data = await this.callGpt(this.buildBatchSystemPrompt(floorMarkup), payload)
|
||||||
|
return this.normalizeBatch(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chuẩn hoá phản hồi batch về map `id -> Suggestion`.
|
||||||
|
* AI có thể trả string JSON, mảng trực tiếp, hoặc bọc trong { results | data | result }.
|
||||||
|
* Item lỗi/không hợp lệ bị bỏ qua để caller fallback rule-based từng cái.
|
||||||
|
*/
|
||||||
|
private static normalizeBatch(raw: any): Map<number, Suggestion> {
|
||||||
|
let data = raw
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data)
|
||||||
|
} catch {
|
||||||
|
throw new Error('OpenAI batch trả về không phải JSON hợp lệ: ' + raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const arr: any[] = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray(data?.results)
|
||||||
|
? data.results
|
||||||
|
: Array.isArray(data?.data)
|
||||||
|
? data.data
|
||||||
|
: Array.isArray(data?.result)
|
||||||
|
? data.result
|
||||||
|
: []
|
||||||
|
|
||||||
|
const map = new Map<number, Suggestion>()
|
||||||
|
for (const entry of arr) {
|
||||||
|
const id = Number(entry?.id)
|
||||||
|
if (!Number.isFinite(id)) continue
|
||||||
|
try {
|
||||||
|
map.set(id, this.normalize(entry))
|
||||||
|
} catch {
|
||||||
|
// item hỏng -> để trống, caller sẽ fallback rule-based
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import xlsx from 'xlsx'
|
import xlsx from 'xlsx'
|
||||||
import ProductService from '#services/product_service'
|
import ProductService from '#services/product_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
import NotificationService from '#services/notification_service'
|
||||||
|
import type { NotificationType } from '#models/notification'
|
||||||
|
|
||||||
export interface ImportRowResult {
|
export interface ImportRowResult {
|
||||||
row: number
|
row: number
|
||||||
|
|
@ -76,6 +78,30 @@ export default class ImportService {
|
||||||
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
|
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Thông báo cho người dùng: cảnh báo nếu import trùng / có dòng lỗi, ngược lại báo thành công.
|
||||||
|
const type: NotificationType =
|
||||||
|
summary.failed > 0 ? 'error' : summary.updated > 0 ? 'warning' : 'success'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (summary.created > 0) parts.push(`${summary.created} sản phẩm mới`)
|
||||||
|
if (summary.updated > 0) parts.push(`${summary.updated} sản phẩm đã tồn tại (import trùng, đã cập nhật)`)
|
||||||
|
if (summary.failed > 0) parts.push(`${summary.failed} dòng lỗi`)
|
||||||
|
await NotificationService.notify({
|
||||||
|
type,
|
||||||
|
title:
|
||||||
|
summary.updated > 0
|
||||||
|
? 'Import trùng sản phẩm đã có'
|
||||||
|
: summary.failed > 0
|
||||||
|
? 'Import Excel có lỗi'
|
||||||
|
: 'Import Excel thành công',
|
||||||
|
message: `Đã xử lý ${summary.total} dòng: ${parts.join(', ') || 'không có thay đổi'}.`,
|
||||||
|
meta: {
|
||||||
|
total: summary.total,
|
||||||
|
created: summary.created,
|
||||||
|
updated: summary.updated,
|
||||||
|
failed: summary.failed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import Notification, { type NotificationType } from '#models/notification'
|
||||||
|
|
||||||
|
interface NotifyInput {
|
||||||
|
type: NotificationType
|
||||||
|
title: string
|
||||||
|
message?: string | null
|
||||||
|
meta?: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListParams {
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
type?: NotificationType
|
||||||
|
/** Lọc theo trạng thái đọc: true = chỉ đã đọc, false = chỉ chưa đọc. */
|
||||||
|
isRead?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thông báo hướng người dùng (sản phẩm mới khi sync, import trùng, kết quả thao tác).
|
||||||
|
* Tương tự LogService nhưng có trạng thái đọc/chưa đọc và hiển thị trên UI.
|
||||||
|
*/
|
||||||
|
export default class NotificationService {
|
||||||
|
static async notify(input: NotifyInput): Promise<Notification> {
|
||||||
|
return Notification.create({
|
||||||
|
type: input.type,
|
||||||
|
title: input.title,
|
||||||
|
message: input.message ?? null,
|
||||||
|
meta: input.meta ?? null,
|
||||||
|
isRead: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tạo nhiều thông báo một lượt. */
|
||||||
|
static async notifyMany(inputs: NotifyInput[]): Promise<void> {
|
||||||
|
if (!inputs.length) return
|
||||||
|
await Notification.createMany(
|
||||||
|
inputs.map((i) => ({
|
||||||
|
type: i.type,
|
||||||
|
title: i.title,
|
||||||
|
message: i.message ?? null,
|
||||||
|
meta: i.meta ?? null,
|
||||||
|
isRead: false,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list(params: ListParams) {
|
||||||
|
const page = params.page ?? 1
|
||||||
|
const perPage = params.perPage ?? 25
|
||||||
|
|
||||||
|
const query = Notification.query().orderBy('created_at', 'desc')
|
||||||
|
if (params.type) query.where('type', params.type)
|
||||||
|
if (params.isRead !== undefined) query.where('is_read', params.isRead)
|
||||||
|
|
||||||
|
return query.paginate(page, perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Số thông báo chưa đọc (cho badge). */
|
||||||
|
static async unreadCount(): Promise<number> {
|
||||||
|
const row = await Notification.query().where('is_read', false).count('* as total').first()
|
||||||
|
return Number((row as any)?.$extras?.total ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Đánh dấu 1 thông báo là đã đọc. */
|
||||||
|
static async markRead(id: number): Promise<Notification> {
|
||||||
|
const notification = await Notification.findOrFail(id)
|
||||||
|
notification.isRead = true
|
||||||
|
await notification.save()
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Đánh dấu tất cả (hoặc theo loại) là đã đọc. Trả về số bản ghi cập nhật. */
|
||||||
|
static async markAllRead(type?: NotificationType): Promise<number> {
|
||||||
|
const query = Notification.query().where('is_read', false)
|
||||||
|
if (type) query.where('type', type)
|
||||||
|
const result = await query.update({ is_read: true })
|
||||||
|
return Array.isArray(result) ? Number(result[0]) : Number(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async destroy(id: number): Promise<void> {
|
||||||
|
const notification = await Notification.findOrFail(id)
|
||||||
|
await notification.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import env from '#start/env'
|
||||||
import Product from '#models/product'
|
import Product from '#models/product'
|
||||||
import ErpService from '#services/erp_service'
|
import ErpService from '#services/erp_service'
|
||||||
import EbayService from '#services/ebay_service'
|
import EbayService from '#services/ebay_service'
|
||||||
import AiService from '#services/ai_service'
|
import AiService, { mapWithConcurrency } from '#services/ai_service'
|
||||||
import type { Suggestion } from '#services/ai_service'
|
import type { Suggestion } from '#services/ai_service'
|
||||||
import HistoryService from '#services/history_service'
|
import HistoryService from '#services/history_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
|
@ -47,6 +47,77 @@ export default class PricingService {
|
||||||
|
|
||||||
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
|
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
|
||||||
|
|
||||||
|
return this.persist({ product, dataSources, dataEbay, suggestion, username, forceApply })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gợi ý giá cho NHIỀU product một lượt (batch).
|
||||||
|
*
|
||||||
|
* Tối ưu tốc độ khi có nhiều sản phẩm:
|
||||||
|
* 1. Lấy dữ liệu supplier + eBay của các product song song (giới hạn concurrency
|
||||||
|
* vì eBay/scrape nặng).
|
||||||
|
* 2. Gọi AI theo LÔ — nhiều product chung 1 request GPT (xem `AiService.suggestBatch`).
|
||||||
|
* 3. Lưu history/price cho từng product (song song có giới hạn).
|
||||||
|
*
|
||||||
|
* Trả về mảng kết quả cùng thứ tự với các product tải được (product không tồn
|
||||||
|
* tại sẽ bị bỏ qua).
|
||||||
|
*/
|
||||||
|
static async suggestForProducts(
|
||||||
|
productIds: number[],
|
||||||
|
username: string,
|
||||||
|
forceApply = false
|
||||||
|
): Promise<SuggestResult[]> {
|
||||||
|
if (!productIds.length) return []
|
||||||
|
|
||||||
|
// Bỏ qua sản phẩm rác đã gắn cờ noListing.
|
||||||
|
const products = await Product.query().whereIn('id', productIds).where('no_listing', false)
|
||||||
|
if (!products.length) return []
|
||||||
|
|
||||||
|
// 1. Thu thập dữ liệu đầu vào (song song, giới hạn concurrency).
|
||||||
|
const inputs = await mapWithConcurrency(products, 5, async (product) => {
|
||||||
|
const [dataSources, dataEbay] = await Promise.all([
|
||||||
|
ErpService.getSupplierPricing(product),
|
||||||
|
EbayService.getMarketData(product),
|
||||||
|
])
|
||||||
|
return { product, dataSources, dataEbay }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Gọi AI theo lô (gộp nhiều product / 1 request GPT).
|
||||||
|
const suggestions = await AiService.suggestBatch(inputs)
|
||||||
|
|
||||||
|
// 3. Lưu kết quả cho từng product (song song, giới hạn concurrency).
|
||||||
|
return mapWithConcurrency(inputs, 5, (input, idx) =>
|
||||||
|
this.persist({
|
||||||
|
product: input.product,
|
||||||
|
dataSources: input.dataSources,
|
||||||
|
dataEbay: input.dataEbay,
|
||||||
|
suggestion: suggestions[idx],
|
||||||
|
username,
|
||||||
|
forceApply,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lưu snapshot history + áp giá hybrid + ghi log cho 1 product đã có suggestion.
|
||||||
|
* Tách riêng để dùng chung giữa luồng single (`suggestForProduct`) và batch
|
||||||
|
* (`suggestForProducts`).
|
||||||
|
*/
|
||||||
|
private static async persist({
|
||||||
|
product,
|
||||||
|
dataSources,
|
||||||
|
dataEbay,
|
||||||
|
suggestion,
|
||||||
|
username,
|
||||||
|
forceApply,
|
||||||
|
}: {
|
||||||
|
product: Product
|
||||||
|
dataSources: Awaited<ReturnType<typeof ErpService.getSupplierPricing>>
|
||||||
|
dataEbay: Awaited<ReturnType<typeof EbayService.getMarketData>>
|
||||||
|
suggestion: Suggestion
|
||||||
|
username: string
|
||||||
|
forceApply: boolean
|
||||||
|
}): Promise<SuggestResult> {
|
||||||
const history = await HistoryService.record({
|
const history = await HistoryService.record({
|
||||||
username,
|
username,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
|
|
@ -111,9 +182,9 @@ export default class PricingService {
|
||||||
return product
|
return product
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lấy danh sách id product để chạy batch (mặc định toàn bộ). */
|
/** Lấy danh sách id product để chạy batch (mặc định toàn bộ, trừ sản phẩm noListing). */
|
||||||
static async productIdsForBatch(): Promise<number[]> {
|
static async productIdsForBatch(): Promise<number[]> {
|
||||||
const rows = await Product.query().select('id')
|
const rows = await Product.query().where('no_listing', false).select('id')
|
||||||
return rows.map((r) => r.id)
|
return rows.map((r) => r.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
import Product from '#models/product'
|
import Product from '#models/product'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ interface ProductData {
|
||||||
type?: string | null
|
type?: string | null
|
||||||
erpId?: string | null
|
erpId?: string | null
|
||||||
warehouse?: string | null
|
warehouse?: string | null
|
||||||
|
noListing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,6 +75,31 @@ export default class ProductService {
|
||||||
return product
|
return product
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox).
|
||||||
|
* Sản phẩm noListing sẽ bị bỏ qua khi chạy gợi ý giá.
|
||||||
|
*/
|
||||||
|
static async setNoListing(
|
||||||
|
ids: number[],
|
||||||
|
noListing: boolean,
|
||||||
|
username: string
|
||||||
|
): Promise<{ updated: number }> {
|
||||||
|
if (!ids.length) return { updated: 0 }
|
||||||
|
|
||||||
|
const updated = await Product.query()
|
||||||
|
.whereIn('id', ids)
|
||||||
|
.update({ no_listing: noListing, updated_at: DateTime.now().toSQL() })
|
||||||
|
|
||||||
|
await LogService.record({
|
||||||
|
username,
|
||||||
|
actionName: noListing ? 'Gắn cờ không gợi ý giá' : 'Bỏ cờ không gợi ý giá',
|
||||||
|
action: 'update',
|
||||||
|
meta: { ids, noListing },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { updated: Array.isArray(updated) ? Number(updated[0]) : Number(updated) }
|
||||||
|
}
|
||||||
|
|
||||||
static async destroy(id: number, username: string): Promise<void> {
|
static async destroy(id: number, username: string): Promise<void> {
|
||||||
const product = await Product.findOrFail(id)
|
const product = await Product.findOrFail(id)
|
||||||
const snapshot = product.serialize()
|
const snapshot = product.serialize()
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,23 @@ export async function enqueuePricingSuggest(productId: number, username: string)
|
||||||
return pricingQueue().add('suggest', { productId, username })
|
return pricingQueue().add('suggest', { productId, username })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Đẩy nhiều job gợi ý giá (batch). */
|
/** Số product gộp trong 1 job batch (mỗi job xử lý 1 request GPT gộp). */
|
||||||
|
export const PRICING_BATCH_CHUNK_SIZE = 25
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đẩy job gợi ý giá hàng loạt: chia `productIds` thành các lô
|
||||||
|
* `PRICING_BATCH_CHUNK_SIZE`, mỗi lô 1 job `suggestBatch` (gộp 1 request GPT/lô).
|
||||||
|
* Giảm số job & số round-trip GPT từ N -> ceil(N/chunk).
|
||||||
|
*/
|
||||||
export async function enqueuePricingBatch(productIds: number[], username: string) {
|
export async function enqueuePricingBatch(productIds: number[], username: string) {
|
||||||
return pricingQueue().addBulk(
|
const jobs: Array<{ name: string; data: { productIds: number[]; username: string } }> = []
|
||||||
productIds.map((productId) => ({ name: 'suggest', data: { productId, username } }))
|
for (let i = 0; i < productIds.length; i += PRICING_BATCH_CHUNK_SIZE) {
|
||||||
)
|
jobs.push({
|
||||||
|
name: 'suggestBatch',
|
||||||
|
data: { productIds: productIds.slice(i, i + PRICING_BATCH_CHUNK_SIZE), username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pricingQueue().addBulk(jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */
|
/** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import ProductService from '#services/product_service'
|
import ProductService from '#services/product_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
import NotificationService from '#services/notification_service'
|
||||||
import ErpService, { type ErpProductItem } from '#services/erp_service'
|
import ErpService, { type ErpProductItem } from '#services/erp_service'
|
||||||
import { enqueueProductUpserts } from '#services/queue_service'
|
import { enqueueProductUpserts } from '#services/queue_service'
|
||||||
import { convertCondition } from '#helpers/condition'
|
|
||||||
|
|
||||||
export interface SyncSummary {
|
export interface SyncSummary {
|
||||||
/** Tổng số bản ghi ERP tự báo cáo (field `total`) — CHỈ tham khảo, không tin cậy. */
|
/** Tổng số bản ghi ERP tự báo cáo (field `total`) — CHỈ tham khảo, không tin cậy. */
|
||||||
|
|
@ -114,7 +114,23 @@ export default class SyncService {
|
||||||
* Cố tình ném lỗi khi thất bại để BullMQ tự retry job.
|
* Cố tình ném lỗi khi thất bại để BullMQ tự retry job.
|
||||||
*/
|
*/
|
||||||
static async upsertProduct(item: ErpProductItem): Promise<{ sku: string; condition: string; created: boolean }> {
|
static async upsertProduct(item: ErpProductItem): Promise<{ sku: string; condition: string; created: boolean }> {
|
||||||
const { created } = await ProductService.upsert(item)
|
const { product, created } = await ProductService.upsert(item)
|
||||||
|
|
||||||
|
// Sản phẩm MỚI từ ERP -> báo cho người dùng xem có cần gắn cờ noListing (bỏ qua gợi ý giá) không.
|
||||||
|
if (created) {
|
||||||
|
await NotificationService.notify({
|
||||||
|
type: 'news',
|
||||||
|
title: 'Sản phẩm mới từ ERP',
|
||||||
|
message: `SKU ${product.sku} (${product.condition}) vừa được đồng bộ. Kiểm tra xem có cần gắn cờ "Không gợi ý giá" cho sản phẩm này không.`,
|
||||||
|
meta: {
|
||||||
|
productId: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
condition: product.condition,
|
||||||
|
warehouse: product.warehouse,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { sku: item.sku, condition: item.condition, created }
|
return { sku: item.sku, condition: item.condition, created }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ const base = {
|
||||||
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
||||||
|
warehouse: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
|
noListing: vine.boolean().optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createProductValidator = vine.compile(vine.object(base))
|
export const createProductValidator = vine.compile(vine.object(base))
|
||||||
|
|
@ -35,6 +37,18 @@ export const updateProductValidator = vine.compile(
|
||||||
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
||||||
|
warehouse: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
|
noListing: vine.boolean().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox).
|
||||||
|
*/
|
||||||
|
export const setNoListingValidator = vine.compile(
|
||||||
|
vine.object({
|
||||||
|
ids: vine.array(vine.number().min(1)).minLength(1),
|
||||||
|
noListing: vine.boolean(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -42,11 +56,12 @@ export const listProductValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
page: vine.number().min(1).optional(),
|
page: vine.number().min(1).optional(),
|
||||||
perPage: vine.number().min(1).max(200).optional(),
|
perPage: vine.number().min(1).max(200).optional(),
|
||||||
sku: vine.string().trim(),
|
sku: vine.string().trim().optional(),
|
||||||
condition: vine.string().trim(),
|
condition: vine.string().trim().optional(),
|
||||||
warehouse: vine.string().trim().optional(),
|
warehouse: vine.string().trim().optional(),
|
||||||
type: vine.string().trim().optional(),
|
type: vine.string().trim().optional(),
|
||||||
order: vine.string().trim().optional(),
|
order: vine.string().trim().optional(),
|
||||||
direction: vine.string().trim().optional(),
|
direction: vine.string().trim().optional(),
|
||||||
|
noListing: vine.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default class AiSuggest extends BaseCommand {
|
||||||
async run() {
|
async run() {
|
||||||
const { default: Product } = await import('#models/product')
|
const { default: Product } = await import('#models/product')
|
||||||
const { default: PricingService } = await import('#services/pricing_service')
|
const { default: PricingService } = await import('#services/pricing_service')
|
||||||
const { default: AiService } = await import('#services/ai_service')
|
const { default: AiService, mapWithConcurrency } = await import('#services/ai_service')
|
||||||
const { default: ErpService } = await import('#services/erp_service')
|
const { default: ErpService } = await import('#services/erp_service')
|
||||||
const { default: EbayService } = await import('#services/ebay_service')
|
const { default: EbayService } = await import('#services/ebay_service')
|
||||||
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
||||||
|
|
@ -89,51 +89,53 @@ export default class AiSuggest extends BaseCommand {
|
||||||
let ok = 0
|
let ok = 0
|
||||||
let fail = 0
|
let fail = 0
|
||||||
|
|
||||||
for (const id of ids) {
|
try {
|
||||||
try {
|
if (this.dryRun) {
|
||||||
if (this.dryRun) {
|
// Dry-run: chạy luồng AI theo LÔ (gộp request GPT) nhưng KHÔNG ghi DB.
|
||||||
// Dry-run: chạy y hệt luồng AI nhưng không ghi DB.
|
const products = await Product.query().whereIn('id', ids)
|
||||||
const product = await Product.findOrFail(id)
|
const inputs = await mapWithConcurrency(products, 5, async (product: any) => {
|
||||||
const [dataSources, dataEbay] = await Promise.all([
|
const [dataSources, dataEbay] = await Promise.all([
|
||||||
ErpService.getSupplierPricing(product),
|
ErpService.getSupplierPricing(product),
|
||||||
EbayService.getMarketData(product),
|
EbayService.getMarketData(product),
|
||||||
])
|
])
|
||||||
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
|
return { product, dataSources, dataEbay }
|
||||||
|
})
|
||||||
|
const suggestions = await AiService.suggestBatch(inputs)
|
||||||
|
inputs.forEach((input, idx) => {
|
||||||
|
const suggestion = suggestions[idx]
|
||||||
rows.push([
|
rows.push([
|
||||||
String(id),
|
String(input.product.id),
|
||||||
product.sku,
|
input.product.sku,
|
||||||
`$${Number(product.price)}`,
|
`$${Number(input.product.price)}`,
|
||||||
`$${suggestion.suggestedPrice}`,
|
`$${suggestion.suggestedPrice}`,
|
||||||
'—',
|
'—',
|
||||||
suggestion.engine,
|
suggestion.engine,
|
||||||
])
|
])
|
||||||
} else {
|
ok++
|
||||||
const result = await PricingService.suggestForProduct(id, this.username, this.force)
|
})
|
||||||
|
fail = ids.length - inputs.length
|
||||||
|
} else {
|
||||||
|
// Non-dry-run: gợi ý + lưu DB theo LÔ.
|
||||||
|
const results = await PricingService.suggestForProducts(ids, this.username, this.force)
|
||||||
|
const prods = await Product.query().whereIn('id', ids).select('id', 'sku')
|
||||||
|
const skuMap = new Map<number, string>()
|
||||||
|
prods.forEach((p: any) => skuMap.set(p.id, p.sku))
|
||||||
|
for (const result of results) {
|
||||||
rows.push([
|
rows.push([
|
||||||
String(id),
|
String(result.productId),
|
||||||
'', // SKU điền sau (suggestForProduct không trả sku)
|
skuMap.get(result.productId) ?? '',
|
||||||
`$${result.oldPrice}`,
|
`$${result.oldPrice}`,
|
||||||
`$${result.suggestion.suggestedPrice}`,
|
`$${result.suggestion.suggestedPrice}`,
|
||||||
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
|
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
|
||||||
result.suggestion.engine,
|
result.suggestion.engine,
|
||||||
])
|
])
|
||||||
|
ok++
|
||||||
}
|
}
|
||||||
ok++
|
fail = ids.length - results.length
|
||||||
} catch (error) {
|
|
||||||
fail++
|
|
||||||
rows.push([String(id), '—', '—', '—', `LỖI: ${(error as Error).message}`, '—'])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SKU cho nhánh non-dry-run (suggestForProduct không trả sku) — nạp 1 lần.
|
|
||||||
const skuMap = new Map<number, string>()
|
|
||||||
if (!this.dryRun) {
|
|
||||||
const prods = await Product.query().whereIn('id', ids).select('id', 'sku')
|
|
||||||
prods.forEach((p: any) => skuMap.set(p.id, p.sku))
|
|
||||||
for (const r of rows) {
|
|
||||||
const idNum = Number(r[0])
|
|
||||||
if (skuMap.has(idNum)) r[1] = skuMap.get(idNum)!
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Lỗi khi chạy batch: ${(error as Error).message}`)
|
||||||
|
fail = ids.length - ok
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = this.ui.table()
|
const table = this.ui.table()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ export default class QueueWork extends BaseCommand {
|
||||||
const { productId, username } = job.data
|
const { productId, username } = job.data
|
||||||
return PricingService.suggestForProduct(productId, username)
|
return PricingService.suggestForProduct(productId, username)
|
||||||
}
|
}
|
||||||
|
// Batch: 1 job = 1 lô product = 1 request GPT gộp (xem enqueuePricingBatch).
|
||||||
|
if (job.name === 'suggestBatch') {
|
||||||
|
const { productIds, username } = job.data
|
||||||
|
return PricingService.suggestForProducts(productIds, username)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ connection: redisConnection, concurrency }
|
{ connection: redisConnection, concurrency }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
protected tableName = 'products'
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
// Đánh dấu sản phẩm rác / không cần gợi ý giá. true = bỏ qua khi gợi ý giá.
|
||||||
|
table.boolean('no_listing').notNullable().defaultTo(false)
|
||||||
|
// Lọc nhanh danh sách cần/không cần gợi ý giá.
|
||||||
|
table.index(['no_listing'], 'products_no_listing_index')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.alterTable(this.tableName, (table) => {
|
||||||
|
table.dropIndex(['no_listing'], 'products_no_listing_index')
|
||||||
|
table.dropColumn('no_listing')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||||
|
|
||||||
|
export default class extends BaseSchema {
|
||||||
|
protected tableName = 'notifications'
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
this.schema.createTable(this.tableName, (table) => {
|
||||||
|
table.increments('id').notNullable()
|
||||||
|
// Loại thông báo: warning | error | success | news.
|
||||||
|
table.string('type', 20).notNullable().defaultTo('news')
|
||||||
|
table.string('title', 191).notNullable()
|
||||||
|
table.text('message').nullable()
|
||||||
|
// Đã đọc hay chưa (false = unread).
|
||||||
|
table.boolean('is_read').notNullable().defaultTo(false)
|
||||||
|
// Dữ liệu kèm theo (vd productId, sku) để frontend điều hướng / hành động.
|
||||||
|
table.json('meta').nullable()
|
||||||
|
table.timestamp('created_at').notNullable()
|
||||||
|
table.timestamp('updated_at').notNullable()
|
||||||
|
|
||||||
|
table.index(['is_read'])
|
||||||
|
table.index(['type'])
|
||||||
|
table.index(['created_at'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async down() {
|
||||||
|
this.schema.dropTable(this.tableName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ const ImportsController = () => import('#controllers/imports_controller')
|
||||||
const PricingController = () => import('#controllers/pricing_controller')
|
const PricingController = () => import('#controllers/pricing_controller')
|
||||||
const LogsController = () => import('#controllers/logs_controller')
|
const LogsController = () => import('#controllers/logs_controller')
|
||||||
const HistoriesController = () => import('#controllers/histories_controller')
|
const HistoriesController = () => import('#controllers/histories_controller')
|
||||||
|
const NotificationsController = () => import('#controllers/notifications_controller')
|
||||||
|
|
||||||
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
|
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
|
||||||
router.get('/api/health', async () => ({ ok: true }))
|
router.get('/api/health', async () => ({ ok: true }))
|
||||||
|
|
@ -27,6 +28,8 @@ router
|
||||||
// --- Products (CRUD: manual + sync) ---
|
// --- Products (CRUD: manual + sync) ---
|
||||||
|
|
||||||
router.post('/products', [ProductsController, 'store'])
|
router.post('/products', [ProductsController, 'store'])
|
||||||
|
// Gắn/bỏ cờ noListing hàng loạt — khai báo TRƯỚC '/products/:id' để không bị nuốt bởi route động.
|
||||||
|
router.patch('/products/no-listing', [ProductsController, 'setNoListing'])
|
||||||
router.get('/products/:id', [ProductsController, 'show'])
|
router.get('/products/:id', [ProductsController, 'show'])
|
||||||
router.patch('/products/:id', [ProductsController, 'update'])
|
router.patch('/products/:id', [ProductsController, 'update'])
|
||||||
router.delete('/products/:id', [ProductsController, 'destroy'])
|
router.delete('/products/:id', [ProductsController, 'destroy'])
|
||||||
|
|
@ -43,6 +46,13 @@ router
|
||||||
router.get('/logs', [LogsController, 'index'])
|
router.get('/logs', [LogsController, 'index'])
|
||||||
router.get('/histories', [HistoriesController, 'index'])
|
router.get('/histories', [HistoriesController, 'index'])
|
||||||
router.get('/histories/:id', [HistoriesController, 'show'])
|
router.get('/histories/:id', [HistoriesController, 'show'])
|
||||||
|
|
||||||
|
// --- Notifications (thông báo cho người dùng) ---
|
||||||
|
router.get('/notifications', [NotificationsController, 'index'])
|
||||||
|
router.get('/notifications/unread-count', [NotificationsController, 'unreadCount'])
|
||||||
|
router.patch('/notifications/read-all', [NotificationsController, 'markAllRead'])
|
||||||
|
router.patch('/notifications/:id/read', [NotificationsController, 'markRead'])
|
||||||
|
router.delete('/notifications/:id', [NotificationsController, 'destroy'])
|
||||||
})
|
})
|
||||||
.use(middleware.auth())
|
.use(middleware.auth())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,14 @@
|
||||||
"name": "suggestprice-web",
|
"name": "suggestprice-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mantine/charts": "^8.3.18",
|
||||||
|
"@mantine/core": "^8.3.18",
|
||||||
|
"@mantine/hooks": "^8.3.18",
|
||||||
|
"@mantine/notifications": "^8.3.18",
|
||||||
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.3.0",
|
||||||
"recharts": "^2.12.7"
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -699,6 +705,59 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.5",
|
||||||
|
"@floating-ui/utils": "^0.2.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react": {
|
||||||
|
"version": "0.27.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz",
|
||||||
|
"integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.1.8",
|
||||||
|
"@floating-ui/utils": "^0.2.11",
|
||||||
|
"tabbable": "^6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^1.7.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -749,6 +808,98 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mantine/charts": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-oudif3EUH7Nb9DPm0abAPxpFYDWWjR3k2S5ll0/CcB+pJzlhwaBG19QwpOJaRA6VAvAXDDKOXCO4mi9XCEN78g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "8.3.18",
|
||||||
|
"@mantine/hooks": "8.3.18",
|
||||||
|
"react": "^18.x || ^19.x",
|
||||||
|
"react-dom": "^18.x || ^19.x",
|
||||||
|
"recharts": ">=2.13.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/core": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.16",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react-number-format": "^5.4.4",
|
||||||
|
"react-remove-scroll": "^2.7.1",
|
||||||
|
"react-textarea-autosize": "8.5.9",
|
||||||
|
"type-fest": "^4.41.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/hooks": "8.3.18",
|
||||||
|
"react": "^18.x || ^19.x",
|
||||||
|
"react-dom": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/hooks": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/notifications": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/store": "8.3.18",
|
||||||
|
"react-transition-group": "4.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mantine/core": "8.3.18",
|
||||||
|
"@mantine/hooks": "8.3.18",
|
||||||
|
"react": "^18.x || ^19.x",
|
||||||
|
"react-dom": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mantine/store": {
|
||||||
|
"version": "8.3.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz",
|
||||||
|
"integrity": "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.x || ^19.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1106,6 +1257,18 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1221,6 +1384,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
|
|
@ -1477,6 +1646,12 @@
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-node-es": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
|
@ -1583,6 +1758,25 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-nonce": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internmap": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
|
@ -1781,6 +1975,39 @@
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-number-format": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -1791,6 +2018,53 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-remove-scroll": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-remove-scroll-bar": "^2.3.7",
|
||||||
|
"react-style-singleton": "^2.2.3",
|
||||||
|
"tslib": "^2.1.0",
|
||||||
|
"use-callback-ref": "^1.3.3",
|
||||||
|
"use-sidecar": "^1.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-remove-scroll-bar": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-style-singleton": "^2.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-smooth": {
|
"node_modules/react-smooth": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
|
@ -1806,6 +2080,45 @@
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-style-singleton": {
|
||||||
|
"version": "2.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
|
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-nonce": "^1.0.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-textarea-autosize": {
|
||||||
|
"version": "8.5.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
|
||||||
|
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"use-composed-ref": "^1.3.0",
|
||||||
|
"use-latest": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
|
@ -1855,6 +2168,27 @@
|
||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
|
|
@ -1929,12 +2263,36 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tabbable": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "4.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
|
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|
@ -1966,6 +2324,103 @@
|
||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-callback-ref": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-composed-ref": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-latest": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-isomorphic-layout-effect": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sidecar": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-node-es": "^1.1.0",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/victory-vendor": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mantine/charts": "^8.3.18",
|
||||||
|
"@mantine/core": "^8.3.18",
|
||||||
|
"@mantine/hooks": "^8.3.18",
|
||||||
|
"@mantine/notifications": "^8.3.18",
|
||||||
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.3.0",
|
||||||
"recharts": "^2.12.7"
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,168 +1,187 @@
|
||||||
import { useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { Badge, Box, Button, Group, SimpleGrid, Stack, Title } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import FilterBar from './components/FilterBar.jsx';
|
||||||
import ProductTablePanel from './components/ProductTablePanel.jsx';
|
import ProductTablePanel from './components/ProductTablePanel.jsx';
|
||||||
import ProductFormPanel from './components/ProductFormPanel.jsx';
|
import ProductFormPanel from './components/ProductFormPanel.jsx';
|
||||||
import FeedPanel from './components/FeedPanel.jsx';
|
import FeedPanel from './components/FeedPanel.jsx';
|
||||||
|
import LoginForm from './components/LoginForm.jsx';
|
||||||
const initialErpProducts = [
|
import { logout } from './store/authSlice';
|
||||||
{ id: 1, sku: 'ERP-001', title: 'Apple iPhone 15', category: 'Phone', price: 999, status: 'Active' },
|
import {
|
||||||
{ id: 2, sku: 'ERP-002', title: 'Samsung Galaxy S24', category: 'Phone', price: 899, status: 'Active' },
|
fetchProducts,
|
||||||
{ id: 3, sku: 'ERP-003', title: 'Sony WH-1000XM5', category: 'Audio', price: 349, status: 'Draft' },
|
saveProduct,
|
||||||
];
|
deleteProduct,
|
||||||
|
suggestPrice,
|
||||||
const initialManualProducts = [
|
importProducts,
|
||||||
{ id: 10, sku: 'MAN-001', title: 'Dell XPS 13', category: 'Laptop', price: 1299, status: 'Listed' },
|
} from './store/productsSlice';
|
||||||
{ id: 11, sku: 'MAN-002', title: 'Logitech MX Master 3', category: 'Accessory', price: 99, status: 'Draft' },
|
import {
|
||||||
];
|
fetchNotifications,
|
||||||
|
markNotificationRead,
|
||||||
const initialFeed = [
|
markAllNotificationsRead,
|
||||||
{ id: 1, message: 'Add sản phẩm tên ABCD bị trùng', type: 'warning' },
|
} from './store/notificationsSlice';
|
||||||
{ id: 2, message: 'List thành công sản phẩm XYZ', type: 'success' },
|
import { updateForm, startAdd, startEdit, resetForm } from './store/uiSlice';
|
||||||
];
|
|
||||||
|
|
||||||
const initialForm = {
|
|
||||||
sku: '',
|
|
||||||
title: '',
|
|
||||||
category: '',
|
|
||||||
price: '',
|
|
||||||
status: 'Draft',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(true);
|
const dispatch = useDispatch();
|
||||||
const [currentUser, setCurrentUser] = useState('Nguyễn Văn A');
|
const { user, token } = useSelector((state) => state.auth);
|
||||||
const [erpProducts, setErpProducts] = useState(initialErpProducts);
|
const { erp, manual, saving, saveError, deleting, suggesting, suggestError, importing } =
|
||||||
const [manualProducts, setManualProducts] = useState(initialManualProducts);
|
useSelector((state) => state.products);
|
||||||
const [feedEntries, setFeedEntries] = useState(initialFeed);
|
const notifications = useSelector((state) => state.notifications);
|
||||||
const [formMode, setFormMode] = useState('add');
|
const { editingId, form } = useSelector((state) => state.ui);
|
||||||
const [form, setForm] = useState(initialForm);
|
|
||||||
|
|
||||||
function resetForm() {
|
// Nạp dữ liệu khi đã đăng nhập.
|
||||||
setFormMode('add');
|
useEffect(() => {
|
||||||
setForm(initialForm);
|
if (!token) return;
|
||||||
}
|
dispatch(fetchProducts({ type: 'ERP', page: 1 }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL', page: 1 }));
|
||||||
|
dispatch(fetchNotifications());
|
||||||
|
}, [dispatch, token]);
|
||||||
|
|
||||||
function handleSelectProduct(product, source) {
|
if (!token || !user) {
|
||||||
setFormMode('edit');
|
return <LoginForm />;
|
||||||
setForm({
|
|
||||||
sku: product.sku,
|
|
||||||
title: product.title,
|
|
||||||
category: product.category,
|
|
||||||
price: product.price,
|
|
||||||
status: product.status,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (source === 'erp') {
|
|
||||||
setFeedEntries((prev) => [
|
|
||||||
{ id: Date.now(), message: `Đã chọn ERP product ${product.title} để chỉnh sửa`, type: 'info' },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImportFromErp() {
|
|
||||||
const selected = erpProducts[0];
|
|
||||||
if (!selected) return;
|
|
||||||
setForm({
|
|
||||||
sku: selected.sku,
|
|
||||||
title: selected.title,
|
|
||||||
category: selected.category,
|
|
||||||
price: selected.price,
|
|
||||||
status: 'Draft',
|
|
||||||
});
|
|
||||||
setFeedEntries((prev) => [
|
|
||||||
{ id: Date.now(), message: `Import sản phẩm ${selected.title} từ ERP`, type: 'info' },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(event) {
|
function handleChange(event) {
|
||||||
const { name, value } = event.target;
|
const { name, value } = event.target;
|
||||||
setForm((prev) => ({ ...prev, [name]: value }));
|
dispatch(updateForm({ name, value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const values = {
|
||||||
|
sku: form.sku,
|
||||||
|
condition: form.condition,
|
||||||
|
qty: Number(form.qty) || 0,
|
||||||
|
price: Number(form.price) || 0,
|
||||||
|
warehouse: form.warehouse || null,
|
||||||
|
packageContain: form.packageContain || null,
|
||||||
|
noListing: !!form.noListing,
|
||||||
|
};
|
||||||
|
// Sản phẩm tạo tay thuộc panel Manual.
|
||||||
|
if (!editingId) values.type = 'MANUAL';
|
||||||
|
|
||||||
if (formMode === 'add') {
|
dispatch(saveProduct({ id: editingId, values })).then((action) => {
|
||||||
const newProduct = {
|
if (!action.error) dispatch(resetForm());
|
||||||
id: Date.now(),
|
});
|
||||||
sku: form.sku || `MAN-${Date.now()}`,
|
|
||||||
title: form.title,
|
|
||||||
category: form.category,
|
|
||||||
price: Number(form.price) || 0,
|
|
||||||
status: form.status,
|
|
||||||
};
|
|
||||||
|
|
||||||
setManualProducts((prev) => [newProduct, ...prev]);
|
|
||||||
setFeedEntries((prev) => [
|
|
||||||
{ id: Date.now(), message: `Add sản phẩm ${newProduct.title} thành công`, type: 'success' },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setManualProducts((prev) =>
|
|
||||||
prev.map((item) => (item.sku === form.sku ? { ...item, ...form, price: Number(form.price) || 0 } : item))
|
|
||||||
);
|
|
||||||
setFeedEntries((prev) => [
|
|
||||||
{ id: Date.now(), message: `Cập nhật sản phẩm ${form.title} thành công`, type: 'success' },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Xóa sản phẩm đang sửa (có xác nhận) rồi reset form.
|
||||||
|
function handleDelete() {
|
||||||
|
if (!editingId) return;
|
||||||
|
if (!window.confirm(`Xóa sản phẩm "${form.sku}"?`)) return;
|
||||||
|
dispatch(deleteProduct(editingId)).then((action) => {
|
||||||
|
if (!action.error) dispatch(resetForm());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import Excel nhiều sản phẩm 1 lượt; báo kết quả bằng notification.
|
||||||
|
function handleImport(file) {
|
||||||
|
if (!file) return;
|
||||||
|
dispatch(importProducts(file)).then((action) => {
|
||||||
|
if (action.error) {
|
||||||
|
notifications.show({
|
||||||
|
color: 'red',
|
||||||
|
title: 'Import lỗi',
|
||||||
|
message: action.payload || 'Import thất bại',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = action.payload;
|
||||||
|
notifications.show({
|
||||||
|
color: s.failed > 0 ? 'yellow' : 'green',
|
||||||
|
title: 'Import Excel xong',
|
||||||
|
message: `Tổng ${s.total} dòng: ${s.created} mới, ${s.updated} cập nhật, ${s.failed} lỗi.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi AI gợi ý lại giá; cập nhật aiPrice hiển thị và điền giá đề xuất vào ô Price.
|
||||||
|
function handleSuggest() {
|
||||||
|
if (!editingId) return;
|
||||||
|
dispatch(suggestPrice(editingId)).then((action) => {
|
||||||
|
if (action.error) return;
|
||||||
|
const suggested = action.payload?.suggestion?.suggestedPrice;
|
||||||
|
if (suggested == null) return;
|
||||||
|
dispatch(updateForm({ name: 'aiPrice', value: suggested }));
|
||||||
|
dispatch(updateForm({ name: 'price', value: suggested }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
[user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<Box p="md">
|
||||||
<header className="topbar">
|
<Stack gap="md">
|
||||||
<div className="topbar-left">
|
<Group
|
||||||
{isLoggedIn ? (
|
justify="space-between"
|
||||||
<span className="user-pill">Hi, {currentUser}</span>
|
bg="white"
|
||||||
) : (
|
p="md"
|
||||||
<span className="user-pill">Chưa đăng nhập</span>
|
style={{
|
||||||
)}
|
borderRadius: 'var(--mantine-radius-lg)',
|
||||||
</div>
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge size="lg" variant="light" radius="xl">
|
||||||
|
Hi, {displayName}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<div className="page-title">Listing - Suggest Price</div>
|
<Title order={3}>Listing - Suggest Price</Title>
|
||||||
|
|
||||||
<div className="topbar-actions">
|
<Group gap="sm">
|
||||||
{isLoggedIn ? (
|
<Button onClick={() => dispatch(startAdd())}>+ Add Product</Button>
|
||||||
<button type="button" className="secondary" onClick={() => setIsLoggedIn(false)}>
|
<Button variant="default" onClick={() => dispatch(logout())}>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
</Group>
|
||||||
<button type="button" onClick={() => setIsLoggedIn(true)}>
|
</Group>
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="dashboard-grid">
|
<FilterBar />
|
||||||
<ProductTablePanel
|
|
||||||
title="Product ERP"
|
|
||||||
badge="Source"
|
|
||||||
products={erpProducts}
|
|
||||||
onSelect={(product) => handleSelectProduct(product, 'erp')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProductTablePanel
|
<SimpleGrid cols={{ base: 1, md: 2, xl: 4 }} spacing="md">
|
||||||
title="Product Manual"
|
<ProductTablePanel
|
||||||
badge="Local"
|
title="Product ERP"
|
||||||
products={manualProducts}
|
badge="type = ERP"
|
||||||
onSelect={(product) => handleSelectProduct(product, 'manual')}
|
bucket={erp}
|
||||||
/>
|
onSelect={(product) => dispatch(startEdit(product))}
|
||||||
|
onPageChange={(page) => dispatch(fetchProducts({ type: 'ERP', page }))}
|
||||||
|
/>
|
||||||
|
|
||||||
<ProductFormPanel
|
<ProductTablePanel
|
||||||
formMode={formMode}
|
title="Product Manual"
|
||||||
form={form}
|
badge="type = MANUAL"
|
||||||
onChange={handleChange}
|
bucket={manual}
|
||||||
onSubmit={handleSubmit}
|
onSelect={(product) => dispatch(startEdit(product))}
|
||||||
onImport={handleImportFromErp}
|
onPageChange={(page) => dispatch(fetchProducts({ type: 'MANUAL', page }))}
|
||||||
onClear={resetForm}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<FeedPanel entries={feedEntries} />
|
<ProductFormPanel
|
||||||
</main>
|
formMode={editingId ? 'edit' : 'add'}
|
||||||
</div>
|
form={form}
|
||||||
|
saving={saving}
|
||||||
|
deleting={deleting}
|
||||||
|
suggesting={suggesting}
|
||||||
|
importing={importing}
|
||||||
|
error={saveError}
|
||||||
|
suggestError={suggestError}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onClear={() => dispatch(resetForm())}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onSuggest={handleSuggest}
|
||||||
|
onImport={handleImport}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeedPanel
|
||||||
|
items={notifications.items}
|
||||||
|
loading={notifications.loading}
|
||||||
|
error={notifications.error}
|
||||||
|
onMarkRead={(id) => dispatch(markNotificationRead(id))}
|
||||||
|
onMarkAllRead={() => dispatch(markAllNotificationsRead())}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Helper gọi API backend. Vite proxy chuyển '/api' -> http://localhost:8386.
|
||||||
|
// Tự gắn Bearer token (lưu ở localStorage) cho các endpoint yêu cầu đăng nhập.
|
||||||
|
|
||||||
|
function buildQuery(params) {
|
||||||
|
const usp = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
usp.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const qs = usp.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(path, options = {}) {
|
||||||
|
const { method = 'GET', body, params, auth = true } = options;
|
||||||
|
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (auth) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api${path}${params ? buildQuery(params) : ''}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
data?.message ||
|
||||||
|
data?.errors?.[0]?.message ||
|
||||||
|
`Yêu cầu thất bại (${response.status})`;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lucid paginate trả về { meta, data }; endpoint khác trả về mảng trực tiếp.
|
||||||
|
export function unwrapList(payload) {
|
||||||
|
if (payload && Array.isArray(payload.data)) return payload.data;
|
||||||
|
return Array.isArray(payload) ? payload : [];
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
|
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||||
|
|
||||||
export default function AiResult({ ai }) {
|
export default function AiResult({ ai }) {
|
||||||
if (!ai) return null;
|
if (!ai) return null;
|
||||||
return (
|
return (
|
||||||
<div className="ai-card">
|
<Card withBorder radius="lg" padding="md">
|
||||||
<div>
|
<Group gap="sm" align="center">
|
||||||
<span className="ai-price">${ai.suggestedPrice}</span>
|
<Text fw={700} fz={28} c="blue">
|
||||||
{ai._mock && <span className="mock-badge">MOCK</span>}
|
${ai.suggestedPrice}
|
||||||
</div>
|
</Text>
|
||||||
<div className="ai-range">
|
{ai._mock && (
|
||||||
|
<Badge color="gray" variant="light">
|
||||||
|
MOCK
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
Khoảng đề xuất: ${ai.priceRange?.min} – ${ai.priceRange?.max}
|
Khoảng đề xuất: ${ai.priceRange?.min} – ${ai.priceRange?.max}
|
||||||
</div>
|
</Text>
|
||||||
<div className="ai-reasoning">{ai.reasoning}</div>
|
<Text size="sm" mt="sm">
|
||||||
</div>
|
{ai.reasoning}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,96 @@
|
||||||
export default function FeedPanel({ entries }) {
|
import { Badge, Button, Card, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from '@mantine/core';
|
||||||
return (
|
|
||||||
<section className="panel">
|
export default function FeedPanel({ items, loading, error, onMarkRead, onMarkAllRead }) {
|
||||||
<div className="panel-header">
|
const unread = items.filter((n) => !n.isRead).length;
|
||||||
<h2>New Feed</h2>
|
|
||||||
<span className="panel-badge">Activity</span>
|
return (
|
||||||
</div>
|
<Card
|
||||||
<div className="feed-list">
|
withBorder
|
||||||
{entries.map((entry) => (
|
shadow="sm"
|
||||||
<div key={entry.id} className={`feed-item ${entry.type}`}>
|
radius="lg"
|
||||||
<span className="feed-dot" />
|
padding="md"
|
||||||
<span>{entry.message}</span>
|
h="76vh"
|
||||||
</div>
|
style={{ display: 'flex', flexDirection: 'column' }}
|
||||||
))}
|
>
|
||||||
</div>
|
<Group justify="space-between" mb="sm">
|
||||||
</section>
|
<Title order={4}>New Feed</Title>
|
||||||
)
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" radius="xl">
|
||||||
|
{unread} chưa đọc
|
||||||
|
</Badge>
|
||||||
|
{unread > 0 && (
|
||||||
|
<Button variant="default" size="xs" onClick={onMarkAllRead}>
|
||||||
|
Đọc tất cả
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Đang tải…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Chưa có thông báo
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{items.map((entry) => (
|
||||||
|
<UnstyledButton
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => !entry.isRead && onMarkRead(entry.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 'var(--mantine-radius-md)',
|
||||||
|
border: '1px solid var(--mantine-color-gray-2)',
|
||||||
|
background: entry.isRead
|
||||||
|
? 'var(--mantine-color-gray-0)'
|
||||||
|
: 'var(--mantine-color-blue-0)',
|
||||||
|
opacity: entry.isRead ? 0.7 : 1,
|
||||||
|
cursor: entry.isRead ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
flex: '0 0 auto',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
marginTop: 6,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: entry.isRead
|
||||||
|
? 'var(--mantine-color-gray-4)'
|
||||||
|
: 'var(--mantine-color-blue-6)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{entry.title}
|
||||||
|
</Text>
|
||||||
|
{entry.message && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{entry.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { Button, Group, Paper, Select, TextInput } from '@mantine/core';
|
||||||
|
import { setFilters, clearFilters, refreshProducts } from '../store/productsSlice';
|
||||||
|
|
||||||
|
const CONDITIONS = ['NIB', 'NOB', 'USEB', 'NEW', 'USED', 'REF'];
|
||||||
|
const WAREHOUSES = ['AU', 'US'];
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export default function FilterBar() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const filters = useSelector((state) => state.products.filters);
|
||||||
|
|
||||||
|
// Chỉ ô SKU cần debounce; condition/warehouse là select nên áp dụng ngay.
|
||||||
|
const [sku, setSku] = useState(filters.sku);
|
||||||
|
const isFirst = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirst.current) {
|
||||||
|
isFirst.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
dispatch(setFilters({ sku: sku.trim() }));
|
||||||
|
dispatch(refreshProducts());
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [sku, dispatch]);
|
||||||
|
|
||||||
|
function handleSelect(name, value) {
|
||||||
|
dispatch(setFilters({ [name]: value || '' }));
|
||||||
|
dispatch(refreshProducts());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
setSku('');
|
||||||
|
dispatch(clearFilters());
|
||||||
|
dispatch(refreshProducts());
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilter = sku || filters.condition || filters.warehouse;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper shadow="sm" radius="lg" p="sm">
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<TextInput
|
||||||
|
value={sku}
|
||||||
|
onChange={(e) => setSku(e.target.value)}
|
||||||
|
placeholder="Search SKU…"
|
||||||
|
leftSection={<span aria-hidden>🔍</span>}
|
||||||
|
style={{ flex: 1, minWidth: 220, maxWidth: 400 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Condition"
|
||||||
|
value={filters.condition || null}
|
||||||
|
onChange={(value) => handleSelect('condition', value)}
|
||||||
|
data={CONDITIONS}
|
||||||
|
clearable
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Warehouse"
|
||||||
|
value={filters.warehouse || null}
|
||||||
|
onChange={(value) => handleSelect('warehouse', value)}
|
||||||
|
data={WAREHOUSES}
|
||||||
|
clearable
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasFilter && (
|
||||||
|
<Button variant="default" onClick={handleClear}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { Alert, Button, Center, Paper, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||||
|
import { login } from '../store/authSlice';
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { loading, error } = useSelector((state) => state.auth);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch(login({ username, password }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center mih="100vh" p="md">
|
||||||
|
<Paper shadow="md" radius="lg" p="xl" w={380} maw="100%">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Listing - Suggest Price</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Đăng nhập bằng tài khoản ERP
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" variant="light">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Mật khẩu"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth loading={loading} disabled={!username.trim()}>
|
||||||
|
Đăng nhập
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
import {
|
import { Card, Text, Title } from '@mantine/core';
|
||||||
LineChart,
|
import { LineChart } from '@mantine/charts';
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gộp nhiều series ({date, price}) theo ngày thành 1 mảng cho Recharts.
|
* Gộp nhiều series ({date, price}) theo ngày thành 1 mảng cho biểu đồ.
|
||||||
* lines: [{ key, name, color, data: [{date, price}] }]
|
* lines: [{ key, name, color, data: [{date, price}] }]
|
||||||
*/
|
*/
|
||||||
function mergeByDate(lines) {
|
function mergeByDate(lines) {
|
||||||
|
|
@ -27,40 +19,30 @@ function mergeByDate(lines) {
|
||||||
export default function PriceChart({ title, lines }) {
|
export default function PriceChart({ title, lines }) {
|
||||||
const hasData = lines.some((l) => (l.data || []).length > 0);
|
const hasData = lines.some((l) => (l.data || []).length > 0);
|
||||||
const merged = mergeByDate(lines);
|
const merged = mergeByDate(lines);
|
||||||
|
const series = lines.map((l) => ({ name: l.key, label: l.name, color: l.color }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chart-card">
|
<Card withBorder radius="lg" padding="md">
|
||||||
<h3>{title}</h3>
|
<Title order={4} mb="sm">
|
||||||
|
{title}
|
||||||
|
</Title>
|
||||||
{!hasData ? (
|
{!hasData ? (
|
||||||
<p className="empty">Chưa có dữ liệu.</p>
|
<Text c="dimmed" size="sm">
|
||||||
|
Chưa có dữ liệu.
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<LineChart
|
||||||
<LineChart data={merged} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
|
h={280}
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
|
data={merged}
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
dataKey="date"
|
||||||
<YAxis
|
series={series}
|
||||||
tick={{ fontSize: 11 }}
|
curveType="monotone"
|
||||||
width={56}
|
connectNulls
|
||||||
tickFormatter={(v) => `$${v}`}
|
withDots={false}
|
||||||
domain={['auto', 'auto']}
|
valueFormatter={(v) => `$${v}`}
|
||||||
/>
|
yAxisProps={{ width: 56, domain: ['auto', 'auto'] }}
|
||||||
<Tooltip formatter={(v) => `$${v}`} />
|
/>
|
||||||
<Legend />
|
|
||||||
{lines.map((l) => (
|
|
||||||
<Line
|
|
||||||
key={l.key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={l.key}
|
|
||||||
name={l.name}
|
|
||||||
stroke={l.color}
|
|
||||||
dot={false}
|
|
||||||
connectNulls
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,231 @@
|
||||||
export default function ProductFormPanel({ formMode, form, onChange, onSubmit, onImport, onClear }) {
|
import {
|
||||||
return (
|
Badge,
|
||||||
<section className="panel form-panel">
|
Button,
|
||||||
<div className="panel-header">
|
Card,
|
||||||
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
|
Checkbox,
|
||||||
{formMode === 'add' && (
|
Divider,
|
||||||
<button type="button" className="secondary" onClick={onImport}>
|
FileButton,
|
||||||
Import
|
Flex,
|
||||||
</button>
|
Group,
|
||||||
)}
|
NativeSelect,
|
||||||
</div>
|
NumberInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
<form className="product-form" onSubmit={onSubmit}>
|
const CONDITIONS = [
|
||||||
<label>
|
{ value: "NEW", label: "New" },
|
||||||
SKU
|
{ value: "USED", label: "Used" },
|
||||||
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" />
|
];
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Tên sản phẩm
|
|
||||||
<input name="title" value={form.title} onChange={onChange} placeholder="Tên sản phẩm" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Category
|
|
||||||
<input name="category" value={form.category} onChange={onChange} placeholder="Category" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Price
|
|
||||||
<input name="price" type="number" value={form.price} onChange={onChange} placeholder="Price" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Status
|
|
||||||
<select name="status" value={form.status} onChange={onChange}>
|
|
||||||
<option value="Draft">Draft</option>
|
|
||||||
<option value="Listed">Listed</option>
|
|
||||||
<option value="Active">Active</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="form-actions">
|
const WAREHOUSES = [
|
||||||
<button type="submit">{formMode === 'add' ? 'Add' : 'Save'}</button>
|
{ value: "AU", label: "AU" },
|
||||||
<button type="button" className="secondary" onClick={onClear}>
|
{ value: "US", label: "US" },
|
||||||
Clear
|
];
|
||||||
</button>
|
|
||||||
|
export default function ProductFormPanel({
|
||||||
|
formMode,
|
||||||
|
form,
|
||||||
|
saving,
|
||||||
|
deleting,
|
||||||
|
suggesting,
|
||||||
|
importing,
|
||||||
|
error,
|
||||||
|
suggestError,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onClear,
|
||||||
|
onDelete,
|
||||||
|
onSuggest,
|
||||||
|
onImport,
|
||||||
|
}) {
|
||||||
|
const isEdit = formMode === "edit";
|
||||||
|
// NumberInput / Checkbox trả về value trực tiếp; bọc lại thành sự kiện giả để dùng chung handleChange.
|
||||||
|
const handleNumber = (name) => (value) =>
|
||||||
|
onChange({ target: { name, value } });
|
||||||
|
const handleChecked = (name) => (event) =>
|
||||||
|
onChange({ target: { name, value: event.currentTarget.checked } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
padding="md"
|
||||||
|
h="76vh"
|
||||||
|
style={{ display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Title order={4}>{isEdit ? "Edit Product" : "Add Product"}</Title>
|
||||||
|
<Badge variant="light" radius="xl">
|
||||||
|
{isEdit ? `#${form.sku}` : "Manual"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text c="red" size="sm" mb="sm">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap="sm" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
|
||||||
|
<Flex justify="space-between" gap="sm">
|
||||||
|
<TextInput
|
||||||
|
w={"100%"}
|
||||||
|
label="SKU"
|
||||||
|
name="sku"
|
||||||
|
value={form.sku}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="SKU"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NativeSelect
|
||||||
|
w={"200px"}
|
||||||
|
label="Condition"
|
||||||
|
name="condition"
|
||||||
|
value={form.condition}
|
||||||
|
onChange={onChange}
|
||||||
|
data={CONDITIONS}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Flex justify="space-between" gap="sm" align="flex-end">
|
||||||
|
<NumberInput
|
||||||
|
w={120}
|
||||||
|
label="Qty"
|
||||||
|
min={0}
|
||||||
|
value={form.qty}
|
||||||
|
onChange={handleNumber("qty")}
|
||||||
|
placeholder="Qty"
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||||
|
<NumberInput
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label="Price"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
decimalScale={2}
|
||||||
|
value={form.price}
|
||||||
|
onChange={handleNumber("price")}
|
||||||
|
placeholder="Price"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
isEdit
|
||||||
|
? "Gọi AI gợi ý lại giá"
|
||||||
|
: "Lưu sản phẩm trước khi gợi ý giá"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={onSuggest}
|
||||||
|
loading={suggesting}
|
||||||
|
disabled={!isEdit}
|
||||||
|
>
|
||||||
|
Suggest
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{form.aiPrice != null
|
||||||
|
? `AI gợi ý: $${form.aiPrice}`
|
||||||
|
: "Chưa có giá AI"}
|
||||||
|
</Text>
|
||||||
|
{suggestError && (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
{suggestError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</Flex>
|
||||||
</section>
|
|
||||||
)
|
<NativeSelect
|
||||||
|
label="WH (Warehouse)"
|
||||||
|
name="warehouse"
|
||||||
|
value={form.warehouse}
|
||||||
|
onChange={onChange}
|
||||||
|
data={WAREHOUSES}
|
||||||
|
placeholder="Select Warehouse"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Package Contain"
|
||||||
|
name="packageContain"
|
||||||
|
value={form.packageContain ?? ""}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Package Contain…"
|
||||||
|
minRows={3}
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label="No Listing (skip auto suggestion and listing)"
|
||||||
|
checked={!!form.noListing}
|
||||||
|
onChange={handleChecked("noListing")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm" mt="sm">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button type="submit" loading={saving}>
|
||||||
|
{isEdit ? "Save" : "Add"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<>
|
||||||
|
<Divider label="hoặc" labelPosition="center" />
|
||||||
|
<FileButton onChange={onImport} accept=".xlsx,.xls,.csv">
|
||||||
|
{(props) => (
|
||||||
|
<Button {...props} variant="light" loading={importing}>
|
||||||
|
Import Excel (nhiều sản phẩm)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Cột yêu cầu: sku, condition, qty, price
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={onDelete}
|
||||||
|
loading={deleting}
|
||||||
|
>
|
||||||
|
Xóa sản phẩm
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,108 @@
|
||||||
export default function ProductTablePanel({ title, badge, products, onSelect }) {
|
import { Badge, Button, Card, Group, Loader, Table, Text, Title } from '@mantine/core';
|
||||||
return (
|
|
||||||
<section className="panel">
|
export default function ProductTablePanel({ title, badge, bucket, onSelect, onPageChange }) {
|
||||||
<div className="panel-header">
|
const { items, meta, loading, error } = bucket;
|
||||||
<h2>{title}</h2>
|
|
||||||
<span className="panel-badge">{badge}</span>
|
const currentPage = meta?.currentPage ?? 1;
|
||||||
</div>
|
const lastPage = meta?.lastPage ?? 1;
|
||||||
<table className="data-table">
|
const total = meta?.total ?? items.length;
|
||||||
<thead>
|
|
||||||
<tr>
|
return (
|
||||||
<th>SKU</th>
|
<Card
|
||||||
<th>Tên</th>
|
withBorder
|
||||||
<th>Category</th>
|
shadow="sm"
|
||||||
<th>Price</th>
|
radius="lg"
|
||||||
<th>Status</th>
|
padding="md"
|
||||||
</tr>
|
h="76vh"
|
||||||
</thead>
|
style={{ display: 'flex', flexDirection: 'column' }}
|
||||||
<tbody>
|
>
|
||||||
{products.map((product) => (
|
<Group justify="space-between" mb="sm">
|
||||||
<tr key={product.id} onClick={() => onSelect(product)}>
|
<Title order={4}>{title}</Title>
|
||||||
<td>{product.sku}</td>
|
<Badge variant="light" radius="xl">
|
||||||
<td>{product.title}</td>
|
{badge}
|
||||||
<td>{product.category}</td>
|
</Badge>
|
||||||
<td>${product.price}</td>
|
</Group>
|
||||||
<td>{product.status}</td>
|
|
||||||
</tr>
|
{loading && <Loader size="sm" />}
|
||||||
))}
|
{error && (
|
||||||
</tbody>
|
<Text c="red" size="sm">
|
||||||
</table>
|
{error}
|
||||||
</section>
|
</Text>
|
||||||
)
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<Table stickyHeader highlightOnHover striped verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>SKU</Table.Th>
|
||||||
|
<Table.Th>Condition</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>Price</Table.Th>
|
||||||
|
<Table.Th>WH</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5}>
|
||||||
|
<Text ta="center" c="dimmed" py="md">
|
||||||
|
Chưa có sản phẩm
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
items.map((product) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={product.id}
|
||||||
|
onClick={() => onSelect(product)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Table.Td>{product.sku}</Table.Td>
|
||||||
|
<Table.Td>{product.condition}</Table.Td>
|
||||||
|
<Table.Td>{product.qty}</Table.Td>
|
||||||
|
<Table.Td>${product.price}</Table.Td>
|
||||||
|
<Table.Td>{product.warehouse || '—'}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total > 0 && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="sm"
|
||||||
|
pt="sm"
|
||||||
|
style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Trang {currentPage}/{lastPage} · {total} sản phẩm
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
>
|
||||||
|
‹ Trước
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
disabled={currentPage >= lastPage}
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
>
|
||||||
|
Sau ›
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Button, Group, NativeSelect, TextInput } from '@mantine/core';
|
||||||
|
|
||||||
const CONDITIONS = [
|
const CONDITIONS = [
|
||||||
{ value: 'NEW', label: 'New' },
|
{ value: 'NEW', label: 'New' },
|
||||||
{ value: 'REF', label: 'Refurbished' },
|
{ value: 'REF', label: 'Refurbished' },
|
||||||
|
|
@ -7,34 +9,29 @@ const CONDITIONS = [
|
||||||
export default function SuggestForm({ sku, setSku, condition, setCondition, onSubmit, loading }) {
|
export default function SuggestForm({ sku, setSku, condition, setCondition, onSubmit, loading }) {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="form"
|
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit();
|
onSubmit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="field">
|
<Group align="flex-end" gap="sm">
|
||||||
<label htmlFor="sku">SKU</label>
|
<TextInput
|
||||||
<input
|
label="SKU"
|
||||||
id="sku"
|
|
||||||
value={sku}
|
value={sku}
|
||||||
onChange={(e) => setSku(e.target.value)}
|
onChange={(e) => setSku(e.target.value)}
|
||||||
placeholder="VD: C9200L-24T-4G-E"
|
placeholder="VD: C9200L-24T-4G-E"
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<NativeSelect
|
||||||
<div className="field">
|
label="Condition"
|
||||||
<label htmlFor="condition">Condition</label>
|
value={condition}
|
||||||
<select id="condition" value={condition} onChange={(e) => setCondition(e.target.value)}>
|
onChange={(e) => setCondition(e.target.value)}
|
||||||
{CONDITIONS.map((c) => (
|
data={CONDITIONS}
|
||||||
<option key={c.value} value={c.value}>
|
/>
|
||||||
{c.label}
|
<Button type="submit" loading={loading} disabled={!sku.trim()}>
|
||||||
</option>
|
Suggest
|
||||||
))}
|
</Button>
|
||||||
</select>
|
</Group>
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={loading || !sku.trim()}>
|
|
||||||
{loading ? 'Đang xử lý…' : 'Suggest'}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,29 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App.jsx';
|
import { Provider } from 'react-redux';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
import '@mantine/charts/styles.css';
|
||||||
|
import '@mantine/notifications/styles.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
import store from './store/index.js';
|
||||||
|
import App from './App.jsx';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
primaryColor: 'blue',
|
||||||
|
defaultRadius: 'md',
|
||||||
|
};
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
|
<Notifications position="top-right" />
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
|
</MantineProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { apiFetch } from '../api/client';
|
||||||
|
|
||||||
|
const savedToken = localStorage.getItem('token');
|
||||||
|
const savedUser = localStorage.getItem('user');
|
||||||
|
|
||||||
|
export const login = createAsyncThunk('auth/login', async ({ username, password }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
// POST /api/auth/login -> { user, token }
|
||||||
|
return await apiFetch('/auth/login', { method: 'POST', body: { username, password }, auth: false });
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const authSlice = createSlice({
|
||||||
|
name: 'auth',
|
||||||
|
initialState: {
|
||||||
|
token: savedToken || null,
|
||||||
|
user: savedUser ? JSON.parse(savedUser) : null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
logout(state) {
|
||||||
|
state.token = null;
|
||||||
|
state.user = null;
|
||||||
|
state.error = null;
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(login.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(login.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.token = action.payload.token;
|
||||||
|
state.user = action.payload.user;
|
||||||
|
localStorage.setItem('token', action.payload.token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(action.payload.user));
|
||||||
|
})
|
||||||
|
.addCase(login.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload || 'Đăng nhập thất bại';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { logout } = authSlice.actions;
|
||||||
|
export default authSlice.reducer;
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import authReducer from './authSlice';
|
||||||
|
import productsReducer from './productsSlice';
|
||||||
|
import notificationsReducer from './notificationsSlice';
|
||||||
|
import uiReducer from './uiSlice';
|
||||||
|
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: authReducer,
|
||||||
|
products: productsReducer,
|
||||||
|
notifications: notificationsReducer,
|
||||||
|
ui: uiReducer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default store;
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { apiFetch, unwrapList } from '../api/client';
|
||||||
|
|
||||||
|
// GET /api/notifications (yêu cầu đăng nhập).
|
||||||
|
export const fetchNotifications = createAsyncThunk(
|
||||||
|
'notifications/fetch',
|
||||||
|
async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const payload = await apiFetch('/notifications', { params: { perPage: 100 } });
|
||||||
|
return unwrapList(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const markNotificationRead = createAsyncThunk('notifications/markRead', async (id) => {
|
||||||
|
await apiFetch(`/notifications/${id}/read`, { method: 'PATCH' });
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markAllNotificationsRead = createAsyncThunk('notifications/markAllRead', async () => {
|
||||||
|
await apiFetch('/notifications/read-all', { method: 'PATCH' });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationsSlice = createSlice({
|
||||||
|
name: 'notifications',
|
||||||
|
initialState: {
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchNotifications.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchNotifications.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.items = action.payload;
|
||||||
|
})
|
||||||
|
.addCase(fetchNotifications.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload || 'Không tải được thông báo';
|
||||||
|
})
|
||||||
|
.addCase(markNotificationRead.fulfilled, (state, action) => {
|
||||||
|
const item = state.items.find((n) => n.id === action.payload);
|
||||||
|
if (item) item.isRead = true;
|
||||||
|
})
|
||||||
|
.addCase(markAllNotificationsRead.fulfilled, (state) => {
|
||||||
|
state.items.forEach((n) => {
|
||||||
|
n.isRead = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default notificationsSlice.reducer;
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { apiFetch, unwrapList } from '../api/client';
|
||||||
|
|
||||||
|
const PER_PAGE = 50;
|
||||||
|
|
||||||
|
// type='ERP' -> bucket 'erp'; type='MANUAL' -> bucket 'manual'.
|
||||||
|
function bucketOf(type) {
|
||||||
|
return type === 'ERP' ? 'erp' : 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy danh sách product theo type (ERP | MANUAL) + trang. Endpoint /api/products là public.
|
||||||
|
// Kèm bộ lọc dùng chung (sku LIKE, condition, warehouse) lấy từ state.
|
||||||
|
// page bỏ trống -> giữ nguyên trang hiện tại của bucket đó.
|
||||||
|
export const fetchProducts = createAsyncThunk('products/fetch', async ({ type, page, perPage }, { getState }) => {
|
||||||
|
const state = getState().products;
|
||||||
|
const { filters } = state;
|
||||||
|
const currentPage = page ?? state[bucketOf(type)].meta?.currentPage ?? 1;
|
||||||
|
|
||||||
|
const params = { type, page: currentPage, perPage: perPage ?? PER_PAGE };
|
||||||
|
if (filters.sku) params.sku = filters.sku;
|
||||||
|
if (filters.condition) params.condition = filters.condition;
|
||||||
|
if (filters.warehouse) params.warehouse = filters.warehouse;
|
||||||
|
|
||||||
|
const payload = await apiFetch('/products', { auth: false, params });
|
||||||
|
return { type, items: unwrapList(payload), meta: payload?.meta ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search/filter -> nạp lại cả 2 danh sách và quay về trang 1.
|
||||||
|
export const refreshProducts = () => (dispatch) => {
|
||||||
|
dispatch(fetchProducts({ type: 'ERP', page: 1 }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL', page: 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tạo mới (không id) hoặc cập nhật (có id) — yêu cầu đăng nhập.
|
||||||
|
export const saveProduct = createAsyncThunk(
|
||||||
|
'products/save',
|
||||||
|
async ({ id, values }, { dispatch, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const product = id
|
||||||
|
? await apiFetch(`/products/${id}`, { method: 'PATCH', body: values })
|
||||||
|
: await apiFetch('/products', { method: 'POST', body: values });
|
||||||
|
|
||||||
|
// Nạp lại 2 danh sách (giữ nguyên trang hiện tại) để phản ánh thay đổi.
|
||||||
|
dispatch(fetchProducts({ type: 'ERP' }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL' }));
|
||||||
|
return product;
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Xóa 1 product — yêu cầu đăng nhập. Nạp lại danh sách sau khi xóa.
|
||||||
|
export const deleteProduct = createAsyncThunk(
|
||||||
|
'products/delete',
|
||||||
|
async (id, { dispatch, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await apiFetch(`/products/${id}`, { method: 'DELETE' });
|
||||||
|
dispatch(fetchProducts({ type: 'ERP' }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL' }));
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gọi AI gợi ý lại giá cho 1 product (POST /pricing/suggest/:id). BE tự cập nhật aiPrice.
|
||||||
|
// Trả về SuggestResult { suggestion, applied, oldPrice, newPrice, ... }.
|
||||||
|
export const suggestPrice = createAsyncThunk(
|
||||||
|
'products/suggest',
|
||||||
|
async (id, { dispatch, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const result = await apiFetch(`/pricing/suggest/${id}`, { method: 'POST' });
|
||||||
|
// aiPrice / price có thể đã đổi phía server -> nạp lại danh sách.
|
||||||
|
dispatch(fetchProducts({ type: 'ERP' }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL' }));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import Excel (multipart, field "file") -> upsert nhiều sản phẩm 1 lượt.
|
||||||
|
// apiFetch luôn set Content-Type JSON nên upload file phải dùng fetch trực tiếp với FormData.
|
||||||
|
export const importProducts = createAsyncThunk(
|
||||||
|
'products/import',
|
||||||
|
async (file, { dispatch, rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch('/api/imports/products', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.error || data?.message || `Import thất bại (${response.status})`;
|
||||||
|
throw new Error(typeof message === 'string' ? message : JSON.stringify(message));
|
||||||
|
}
|
||||||
|
// Nạp lại 2 danh sách để thấy sản phẩm vừa import.
|
||||||
|
dispatch(fetchProducts({ type: 'ERP' }));
|
||||||
|
dispatch(fetchProducts({ type: 'MANUAL' }));
|
||||||
|
return data; // ImportSummary { total, created, updated, failed, rows }
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyBucket = () => ({ items: [], meta: null, loading: false, error: null });
|
||||||
|
|
||||||
|
const productsSlice = createSlice({
|
||||||
|
name: 'products',
|
||||||
|
initialState: {
|
||||||
|
erp: emptyBucket(),
|
||||||
|
manual: emptyBucket(),
|
||||||
|
filters: { sku: '', condition: '', warehouse: '' },
|
||||||
|
saving: false,
|
||||||
|
saveError: null,
|
||||||
|
deleting: false,
|
||||||
|
suggesting: false,
|
||||||
|
suggestError: null,
|
||||||
|
importing: false,
|
||||||
|
importError: null,
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
setFilters(state, action) {
|
||||||
|
Object.assign(state.filters, action.payload);
|
||||||
|
},
|
||||||
|
clearFilters(state) {
|
||||||
|
state.filters = { sku: '', condition: '', warehouse: '' };
|
||||||
|
},
|
||||||
|
clearSaveError(state) {
|
||||||
|
state.saveError = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchProducts.pending, (state, action) => {
|
||||||
|
const bucket = state[bucketOf(action.meta.arg.type)];
|
||||||
|
bucket.loading = true;
|
||||||
|
bucket.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchProducts.fulfilled, (state, action) => {
|
||||||
|
const bucket = state[bucketOf(action.payload.type)];
|
||||||
|
bucket.loading = false;
|
||||||
|
bucket.items = action.payload.items;
|
||||||
|
bucket.meta = action.payload.meta;
|
||||||
|
})
|
||||||
|
.addCase(fetchProducts.rejected, (state, action) => {
|
||||||
|
const bucket = state[bucketOf(action.meta.arg.type)];
|
||||||
|
bucket.loading = false;
|
||||||
|
bucket.error = action.error.message || 'Không tải được danh sách';
|
||||||
|
})
|
||||||
|
.addCase(saveProduct.pending, (state) => {
|
||||||
|
state.saving = true;
|
||||||
|
state.saveError = null;
|
||||||
|
})
|
||||||
|
.addCase(saveProduct.fulfilled, (state) => {
|
||||||
|
state.saving = false;
|
||||||
|
})
|
||||||
|
.addCase(saveProduct.rejected, (state, action) => {
|
||||||
|
state.saving = false;
|
||||||
|
state.saveError = action.payload || 'Lưu sản phẩm thất bại';
|
||||||
|
})
|
||||||
|
.addCase(deleteProduct.pending, (state) => {
|
||||||
|
state.deleting = true;
|
||||||
|
state.saveError = null;
|
||||||
|
})
|
||||||
|
.addCase(deleteProduct.fulfilled, (state) => {
|
||||||
|
state.deleting = false;
|
||||||
|
})
|
||||||
|
.addCase(deleteProduct.rejected, (state, action) => {
|
||||||
|
state.deleting = false;
|
||||||
|
state.saveError = action.payload || 'Xóa sản phẩm thất bại';
|
||||||
|
})
|
||||||
|
.addCase(suggestPrice.pending, (state) => {
|
||||||
|
state.suggesting = true;
|
||||||
|
state.suggestError = null;
|
||||||
|
})
|
||||||
|
.addCase(suggestPrice.fulfilled, (state) => {
|
||||||
|
state.suggesting = false;
|
||||||
|
})
|
||||||
|
.addCase(suggestPrice.rejected, (state, action) => {
|
||||||
|
state.suggesting = false;
|
||||||
|
state.suggestError = action.payload || 'Gợi ý giá thất bại';
|
||||||
|
})
|
||||||
|
.addCase(importProducts.pending, (state) => {
|
||||||
|
state.importing = true;
|
||||||
|
state.importError = null;
|
||||||
|
})
|
||||||
|
.addCase(importProducts.fulfilled, (state) => {
|
||||||
|
state.importing = false;
|
||||||
|
})
|
||||||
|
.addCase(importProducts.rejected, (state, action) => {
|
||||||
|
state.importing = false;
|
||||||
|
state.importError = action.payload || 'Import thất bại';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setFilters, clearFilters, clearSaveError } = productsSlice.actions;
|
||||||
|
export default productsSlice.reducer;
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
// Form các trường hiển thị: sku, condition, qty, price, warehouse, packageContain, noListing.
|
||||||
|
// aiPrice là giá do AI gợi ý (chỉ hiển thị / cập nhật qua nút Suggest, không nhập tay).
|
||||||
|
export const emptyForm = {
|
||||||
|
sku: '',
|
||||||
|
condition: 'NEW',
|
||||||
|
qty: 0,
|
||||||
|
price: 0,
|
||||||
|
warehouse: '',
|
||||||
|
packageContain: '',
|
||||||
|
noListing: false,
|
||||||
|
aiPrice: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: 'ui',
|
||||||
|
initialState: {
|
||||||
|
editingId: null, // null = thêm mới, số = đang sửa
|
||||||
|
form: { ...emptyForm },
|
||||||
|
},
|
||||||
|
reducers: {
|
||||||
|
updateForm(state, action) {
|
||||||
|
const { name, value } = action.payload;
|
||||||
|
state.form[name] = value;
|
||||||
|
},
|
||||||
|
startAdd(state) {
|
||||||
|
state.editingId = null;
|
||||||
|
state.form = { ...emptyForm };
|
||||||
|
},
|
||||||
|
startEdit(state, action) {
|
||||||
|
const p = action.payload;
|
||||||
|
state.editingId = p.id;
|
||||||
|
state.form = {
|
||||||
|
sku: p.sku ?? '',
|
||||||
|
condition: p.condition ?? 'NEW',
|
||||||
|
qty: p.qty ?? 0,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
warehouse: p.warehouse ?? '',
|
||||||
|
packageContain: p.packageContain ?? '',
|
||||||
|
noListing: p.noListing ?? false,
|
||||||
|
aiPrice: p.aiPrice ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
resetForm(state) {
|
||||||
|
state.editingId = null;
|
||||||
|
state.form = { ...emptyForm };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { updateForm, startAdd, startEdit, resetForm } = uiSlice.actions;
|
||||||
|
export default uiSlice.reducer;
|
||||||
|
|
@ -1,212 +1,13 @@
|
||||||
|
/*
|
||||||
|
* Giao diện dùng Mantine (component + theme). File này chỉ giữ reset tối thiểu
|
||||||
|
* và màu nền dashboard. KHÔNG thêm rule cho thẻ thuần (button/input…) vì CSS
|
||||||
|
* không-layer sẽ đè lên style layer của Mantine.
|
||||||
|
*/
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
"Segoe UI",
|
|
||||||
Roboto,
|
|
||||||
sans-serif;
|
|
||||||
background: #f3f5f9;
|
background: #f3f5f9;
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 9px 14px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
background: #e5e7eb;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar-left,
|
|
||||||
.topbar-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-pill,
|
|
||||||
.panel-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
|
||||||
height: 86vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th,
|
|
||||||
.data-table td {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 8px;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-panel {
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-form label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-form input,
|
|
||||||
.product-form select {
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item.warning {
|
|
||||||
background: #fff7ed;
|
|
||||||
color: #9a2c00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item.success {
|
|
||||||
background: #ecfdf3;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-item.info {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.dashboard-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.dashboard-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// Cổng backend (AdonisJS) — mặc định 3333 (backend/.env: PORT).
|
||||||
|
// Override khi cần: VITE_API_TARGET=http://localhost:8386 npm run dev
|
||||||
|
const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:3333';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8386',
|
'/api': {
|
||||||
|
target: apiTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue