implemented the forntend screens
This commit is contained in:
Generated
+345
-327
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"axios": "^1.18.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-is": "^19.2.7",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const srcDir = path.join(root, 'src');
|
||||
const localeDir = path.join(srcDir, 'i18n', 'locales');
|
||||
const uiTextFile = path.join(srcDir, 'i18n', 'uiText.ts');
|
||||
const langs = ['ar', 'fr', 'hi', 'ms'];
|
||||
|
||||
const read = (file) => fs.readFileSync(file, 'utf8');
|
||||
const readJson = (file) => JSON.parse(read(file));
|
||||
|
||||
const walk = (dir, out = []) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const flatten = (source, prefix = '', out = {}) => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, fullKey, out);
|
||||
else out[fullKey] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const normalize = (value) => value.trim().replace(/\s+/g, ' ');
|
||||
const isUiPhrase = (value) =>
|
||||
/[A-Za-z]/.test(value) &&
|
||||
!/[{}()[\]=<>]/.test(value) &&
|
||||
!/^(https?:|\/api\/|[.#]?[a-z0-9_-]+$)/i.test(value) &&
|
||||
!/\.(tsx?|jsx?|css|png|jpg|svg|xlsx?)$/i.test(value);
|
||||
|
||||
const allowedSame = new Set([
|
||||
'API', 'CSV', 'DN', 'GRN', 'GST', 'GSTIN', 'HSN', 'HSN/SAC', 'IFSC / SWIFT', 'PDF', 'PO', 'POS', 'QR', 'RFQ', 'SKU', 'SMS', 'SSO', 'UPI', 'VAT',
|
||||
'CGST', 'SGST', 'IGST', 'UPI ID', 'IFSC Code:', 'SWIFT Code:', 'UPI ID:', 'CGST (+)', 'SGST (+)', 'IGST (+)', 'CGST (+):', 'SGST (+):', 'IGST (+):',
|
||||
'Status:', 'Item:', 'Unit *', 'Ref #', 'ⓘ info', '→ stock',
|
||||
]);
|
||||
|
||||
const isCodeLikeLocaleValue = (value) =>
|
||||
!isUiPhrase(value) ||
|
||||
allowedSame.has(value.trim()) ||
|
||||
/[{}()[\]=<>]/.test(value) ||
|
||||
/\b[a-z]+[A-Z][A-Za-z]*\b/.test(value) ||
|
||||
/[&=<>]/.test(value) ||
|
||||
/\b(currentData|totalRow|enableDelete|pageSize|filteredProducts|sessionSales|grandTotal|canCompare|onPayDue|soList|stats)\b/.test(value) ||
|
||||
/^e\.g\./i.test(value) ||
|
||||
/^https?:/i.test(value) ||
|
||||
/^[A-Z0-9_ /:+#.-]+$/.test(value);
|
||||
|
||||
const en = readJson(path.join(localeDir, 'en.json'));
|
||||
const enFlat = flatten(en);
|
||||
const enValues = new Set(Object.values(enFlat).filter((value) => typeof value === 'string').map(normalize));
|
||||
const uiText = read(uiTextFile);
|
||||
const uiTextValues = new Set([...uiText.matchAll(/'([^']+)':\s*'[^']+'/g)].map((match) => normalize(match[1])));
|
||||
|
||||
const headerCandidates = new Map();
|
||||
const addCandidate = (value, file) => {
|
||||
const text = normalize(value);
|
||||
if (!isUiPhrase(text)) return;
|
||||
if (!headerCandidates.has(text)) headerCandidates.set(text, new Set());
|
||||
headerCandidates.get(text).add(path.relative(root, file));
|
||||
};
|
||||
|
||||
for (const file of walk(srcDir)) {
|
||||
const source = read(file);
|
||||
for (const match of source.matchAll(/\bheaders\s*=\s*\{?\s*\[([\s\S]*?)\]\s*\}?/g)) {
|
||||
const body = match[1];
|
||||
for (const stringMatch of body.matchAll(/['"`]([^'"`\n]+)['"`]/g)) addCandidate(stringMatch[1], file);
|
||||
}
|
||||
for (const match of source.matchAll(/\b(?:headers|columns)\s*=\s*\[([\s\S]*?)\]/g)) {
|
||||
const body = match[1];
|
||||
for (const stringMatch of body.matchAll(/['"`]([^'"`\n]+)['"`]/g)) addCandidate(stringMatch[1], file);
|
||||
}
|
||||
}
|
||||
|
||||
const missingHeaderMappings = [...headerCandidates.entries()]
|
||||
.filter(([text]) => !uiTextValues.has(text) && !enValues.has(text))
|
||||
.sort((a, b) => b[1].size - a[1].size || a[0].localeCompare(b[0]))
|
||||
.map(([text, files]) => ({ text, files: [...files] }));
|
||||
|
||||
const untranslatedByLang = {};
|
||||
const translatableUntranslatedByLang = {};
|
||||
for (const lang of langs) {
|
||||
const flat = flatten(readJson(path.join(localeDir, `${lang}.json`)));
|
||||
untranslatedByLang[lang] = [];
|
||||
translatableUntranslatedByLang[lang] = [];
|
||||
for (const [key, enValue] of Object.entries(enFlat)) {
|
||||
if (typeof enValue !== 'string') continue;
|
||||
if (flat[key] === enValue && /[A-Za-z]/.test(enValue)) {
|
||||
const item = { key, value: enValue };
|
||||
untranslatedByLang[lang].push(item);
|
||||
if (!isCodeLikeLocaleValue(enValue)) translatableUntranslatedByLang[lang].push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
missingHeaderMappingsCount: missingHeaderMappings.length,
|
||||
missingHeaderMappings: missingHeaderMappings.slice(0, 250),
|
||||
untranslatedCounts: Object.fromEntries(Object.entries(untranslatedByLang).map(([lang, items]) => [lang, items.length])),
|
||||
translatableUntranslatedCounts: Object.fromEntries(Object.entries(translatableUntranslatedByLang).map(([lang, items]) => [lang, items.length])),
|
||||
untranslatedSamples: Object.fromEntries(Object.entries(untranslatedByLang).map(([lang, items]) => [lang, items.slice(0, 120)])),
|
||||
translatableUntranslatedSamples: Object.fromEntries(Object.entries(translatableUntranslatedByLang).map(([lang, items]) => [lang, items.slice(0, 160)])),
|
||||
}, null, 2));
|
||||
@@ -0,0 +1,468 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const localeDir = path.join(root, 'src', 'i18n', 'locales');
|
||||
const langs = ['ar', 'fr', 'hi', 'ms'];
|
||||
|
||||
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
const en = readJson(path.join(localeDir, 'en.json'));
|
||||
|
||||
const flatten = (source, prefix = '', out = {}) => {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
flatten(value, fullKey, out);
|
||||
} else {
|
||||
out[fullKey] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const setDeep = (target, dottedKey, value) => {
|
||||
const parts = dottedKey.split('.');
|
||||
let current = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
current[part] ??= {};
|
||||
current = current[part];
|
||||
}
|
||||
current[parts.at(-1)] = value;
|
||||
};
|
||||
|
||||
const enFlat = flatten(en);
|
||||
|
||||
const exact = {
|
||||
ar: {
|
||||
'This action cannot be undone.': 'لا يمكن التراجع عن هذا الإجراء.',
|
||||
'This action cannot be undone!': 'لا يمكن التراجع عن هذا الإجراء!',
|
||||
'Overall Information on Single Screen': 'معلومات عامة في شاشة واحدة',
|
||||
'Validation Error': 'خطأ في التحقق',
|
||||
'Access Denied': 'تم رفض الوصول',
|
||||
'Authenticating...': 'جار التحقق من الهوية...',
|
||||
'Change Language': 'تغيير اللغة',
|
||||
'Discount(-)': 'الخصم(-)',
|
||||
'Sent / Open': 'مرسل / مفتوح',
|
||||
'Mark as Sent': 'وضع علامة كمرسل',
|
||||
'Convert to Sales Order': 'تحويل إلى أمر بيع',
|
||||
'All customers': 'كل العملاء',
|
||||
'Select date': 'اختر التاريخ',
|
||||
'Search by Quotation #': 'البحث برقم عرض السعر',
|
||||
},
|
||||
fr: {
|
||||
'This action cannot be undone.': 'Cette action ne peut pas etre annulee.',
|
||||
'This action cannot be undone!': 'Cette action ne peut pas etre annulee!',
|
||||
'Overall Information on Single Screen': 'Informations generales sur un seul ecran',
|
||||
'Validation Error': 'Erreur de validation',
|
||||
'Access Denied': 'Acces refuse',
|
||||
'Authenticating...': 'Authentification...',
|
||||
'Change Language': 'Changer de langue',
|
||||
'Discount(-)': 'Remise(-)',
|
||||
'Sent / Open': 'Envoye / ouvert',
|
||||
'Mark as Sent': 'Marquer comme envoye',
|
||||
'Convert to Sales Order': 'Convertir en commande client',
|
||||
'All customers': 'Tous les clients',
|
||||
'Select date': 'Selectionner une date',
|
||||
'Search by Quotation #': 'Rechercher par devis #',
|
||||
},
|
||||
hi: {
|
||||
'This action cannot be undone.': 'इस कार्रवाई को वापस नहीं किया जा सकता।',
|
||||
'This action cannot be undone!': 'इस कार्रवाई को वापस नहीं किया जा सकता!',
|
||||
'Overall Information on Single Screen': 'एक ही स्क्रीन पर पूरी जानकारी',
|
||||
'Validation Error': 'सत्यापन त्रुटि',
|
||||
'Access Denied': 'पहुंच अस्वीकृत',
|
||||
'Authenticating...': 'प्रमाणीकरण हो रहा है...',
|
||||
'Change Language': 'भाषा बदलें',
|
||||
'Discount(-)': 'छूट(-)',
|
||||
'Sent / Open': 'भेजा गया / खुला',
|
||||
'Mark as Sent': 'भेजा गया चिह्नित करें',
|
||||
'Convert to Sales Order': 'बिक्री आदेश में बदलें',
|
||||
'All customers': 'सभी ग्राहक',
|
||||
'Select date': 'तारीख चुनें',
|
||||
'Search by Quotation #': 'कोटेशन # से खोजें',
|
||||
},
|
||||
ms: {
|
||||
'This action cannot be undone.': 'Tindakan ini tidak boleh dibuat asal.',
|
||||
'This action cannot be undone!': 'Tindakan ini tidak boleh dibuat asal!',
|
||||
'Overall Information on Single Screen': 'Maklumat keseluruhan pada satu skrin',
|
||||
'Validation Error': 'Ralat pengesahan',
|
||||
'Access Denied': 'Akses ditolak',
|
||||
'Authenticating...': 'Mengesahkan...',
|
||||
'Change Language': 'Tukar bahasa',
|
||||
'Discount(-)': 'Diskaun(-)',
|
||||
'Sent / Open': 'Dihantar / dibuka',
|
||||
'Mark as Sent': 'Tanda sebagai dihantar',
|
||||
'Convert to Sales Order': 'Tukar kepada pesanan jualan',
|
||||
'All customers': 'Semua pelanggan',
|
||||
'Select date': 'Pilih tarikh',
|
||||
'Search by Quotation #': 'Cari mengikut sebut harga #',
|
||||
},
|
||||
};
|
||||
|
||||
const wordMaps = {
|
||||
ar: {
|
||||
access: 'وصول', account: 'حساب', accounts: 'حسابات', action: 'إجراء', actions: 'إجراءات', active: 'نشط',
|
||||
add: 'إضافة', added: 'تمت الإضافة', address: 'عنوان', admin: 'مسؤول', all: 'كل', amount: 'مبلغ',
|
||||
and: 'و', apply: 'تطبيق', approved: 'معتمد', available: 'متاح', back: 'رجوع', balance: 'رصيد',
|
||||
bank: 'بنك', barcode: 'باركود', brand: 'علامة تجارية', brands: 'علامات تجارية', cancel: 'إلغاء',
|
||||
cancelled: 'ملغى', cannot: 'لا يمكن', card: 'بطاقة', cart: 'سلة', cash: 'نقد', categories: 'فئات',
|
||||
category: 'فئة', change: 'تغيير', charge: 'رسوم', charges: 'رسوم', city: 'مدينة', close: 'إغلاق',
|
||||
closed: 'مغلق', code: 'رمز', company: 'شركة', comparison: 'مقارنة', completed: 'مكتمل', confirm: 'تأكيد',
|
||||
contact: 'اتصال', country: 'دولة', create: 'إنشاء', created: 'تم الإنشاء', customer: 'عميل',
|
||||
customers: 'عملاء', dashboard: 'لوحة التحكم', date: 'تاريخ', delete: 'حذف', deleted: 'تم الحذف',
|
||||
delivery: 'تسليم', description: 'وصف', details: 'تفاصيل', discount: 'خصم', draft: 'مسودة', due: 'مستحق',
|
||||
edit: 'تعديل', email: 'بريد إلكتروني', enter: 'أدخل', error: 'خطأ', expense: 'مصروف', expenses: 'مصروفات',
|
||||
expired: 'منتهي', failed: 'فشل', filter: 'تصفية', for: 'لـ', found: 'موجود', from: 'من', gateway: 'بوابة',
|
||||
gateways: 'بوابات', goods: 'بضائع', group: 'مجموعة', information: 'معلومات', inventory: 'مخزون',
|
||||
invoice: 'فاتورة', invoices: 'فواتير', item: 'صنف', items: 'أصناف', ledger: 'سجل', list: 'قائمة',
|
||||
loading: 'جار التحميل', login: 'تسجيل الدخول', manage: 'إدارة', management: 'إدارة', material: 'مادة',
|
||||
method: 'طريقة', methods: 'طرق', mobile: 'جوال', name: 'اسم', new: 'جديد', no: 'لا', note: 'ملاحظة',
|
||||
notes: 'ملاحظات', number: 'رقم', of: 'من', on: 'على', open: 'مفتوح', opening: 'افتتاحي', optional: 'اختياري',
|
||||
order: 'طلب', orders: 'طلبات', paid: 'مدفوع', password: 'كلمة المرور', payment: 'دفع', payments: 'مدفوعات',
|
||||
pending: 'معلق', phone: 'هاتف', please: 'يرجى', pos: 'نقطة البيع', posted: 'مرحل', price: 'سعر',
|
||||
print: 'طباعة', product: 'منتج', purchase: 'شراء', quantity: 'كمية', quotation: 'عرض سعر', quotations: 'عروض أسعار',
|
||||
receipt: 'إيصال', receipts: 'إيصالات', received: 'مستلم', record: 'تسجيل', reference: 'مرجع', refund: 'استرداد',
|
||||
rejected: 'مرفوض', request: 'طلب', requests: 'طلبات', required: 'مطلوب', reset: 'إعادة تعيين', return: 'إرجاع',
|
||||
returns: 'مرتجعات', role: 'دور', sale: 'بيع', sales: 'مبيعات', save: 'حفظ', search: 'بحث', select: 'اختر',
|
||||
selected: 'محدد', sent: 'مرسل', session: 'جلسة', sessions: 'جلسات', settings: 'إعدادات', sign: 'تسجيل',
|
||||
state: 'ولاية', status: 'حالة', stock: 'مخزون', store: 'متجر', stores: 'متاجر', subcategory: 'فئة فرعية',
|
||||
submit: 'إرسال', success: 'نجاح', successfully: 'بنجاح', supplier: 'مورد', suppliers: 'موردون', summary: 'ملخص',
|
||||
tax: 'ضريبة', taxes: 'ضرائب', terms: 'شروط', this: 'هذا', to: 'إلى', total: 'الإجمالي', transaction: 'معاملة',
|
||||
type: 'نوع', undone: 'التراجع', unit: 'وحدة', units: 'وحدات', update: 'تحديث', updated: 'تم التحديث',
|
||||
upload: 'رفع', user: 'مستخدم', users: 'مستخدمون', value: 'قيمة', view: 'عرض', want: 'تريد', warning: 'تحذير',
|
||||
with: 'مع', you: 'أنت', your: 'الخاص بك',
|
||||
},
|
||||
fr: {
|
||||
access: 'acces', account: 'compte', accounts: 'comptes', action: 'action', actions: 'actions', active: 'actif',
|
||||
add: 'ajouter', added: 'ajoute', address: 'adresse', admin: 'administrateur', all: 'tous', amount: 'montant',
|
||||
and: 'et', apply: 'appliquer', approved: 'approuve', available: 'disponible', back: 'retour', balance: 'solde',
|
||||
bank: 'banque', barcode: 'code-barres', brand: 'marque', brands: 'marques', cancel: 'annuler', cancelled: 'annule',
|
||||
cannot: 'ne peut pas', card: 'carte', cart: 'panier', cash: 'especes', categories: 'categories', category: 'categorie',
|
||||
change: 'changer', charge: 'frais', charges: 'frais', city: 'ville', close: 'fermer', closed: 'ferme', code: 'code',
|
||||
company: 'entreprise', comparison: 'comparaison', completed: 'termine', confirm: 'confirmer', contact: 'contact',
|
||||
country: 'pays', create: 'creer', created: 'cree', customer: 'client', customers: 'clients', dashboard: 'tableau de bord',
|
||||
date: 'date', delete: 'supprimer', deleted: 'supprime', delivery: 'livraison', description: 'description',
|
||||
details: 'details', discount: 'remise', draft: 'brouillon', due: 'du', edit: 'modifier', email: 'e-mail',
|
||||
enter: 'saisir', error: 'erreur', expense: 'depense', expenses: 'depenses', expired: 'expire', failed: 'echec',
|
||||
filter: 'filtrer', for: 'pour', found: 'trouve', from: 'de', gateway: 'passerelle', gateways: 'passerelles',
|
||||
goods: 'marchandises', group: 'groupe', information: 'information', inventory: 'stock', invoice: 'facture',
|
||||
invoices: 'factures', item: 'article', items: 'articles', ledger: 'grand livre', list: 'liste', loading: 'chargement',
|
||||
login: 'connexion', manage: 'gerer', management: 'gestion', material: 'materiel', method: 'methode', methods: 'methodes',
|
||||
mobile: 'mobile', name: 'nom', new: 'nouveau', no: 'aucun', note: 'note', notes: 'notes', number: 'numero',
|
||||
of: 'de', on: 'sur', open: 'ouvert', opening: 'ouverture', optional: 'facultatif', order: 'commande',
|
||||
orders: 'commandes', paid: 'paye', password: 'mot de passe', payment: 'paiement', payments: 'paiements',
|
||||
pending: 'en attente', phone: 'telephone', please: 'veuillez', pos: 'caisse', posted: 'valide', price: 'prix',
|
||||
print: 'imprimer', product: 'produit', purchase: 'achat', quantity: 'quantite', quotation: 'devis', quotations: 'devis',
|
||||
receipt: 'recu', receipts: 'recus', received: 'recu', record: 'enregistrer', reference: 'reference', refund: 'remboursement',
|
||||
rejected: 'rejete', request: 'demande', requests: 'demandes', required: 'obligatoire', reset: 'reinitialiser',
|
||||
return: 'retour', returns: 'retours', role: 'role', sale: 'vente', sales: 'ventes', save: 'enregistrer',
|
||||
search: 'rechercher', select: 'selectionner', selected: 'selectionne', sent: 'envoye', session: 'session',
|
||||
sessions: 'sessions', settings: 'parametres', sign: 'se connecter', state: 'etat', status: 'statut',
|
||||
stock: 'stock', store: 'magasin', stores: 'magasins', subcategory: 'sous-categorie', submit: 'soumettre',
|
||||
success: 'succes', successfully: 'avec succes', supplier: 'fournisseur', suppliers: 'fournisseurs', summary: 'resume',
|
||||
tax: 'taxe', taxes: 'taxes', terms: 'conditions', this: 'ceci', to: 'a', total: 'total', transaction: 'transaction',
|
||||
type: 'type', undone: 'annule', unit: 'unite', units: 'unites', update: 'mettre a jour', updated: 'mis a jour',
|
||||
upload: 'televerser', user: 'utilisateur', users: 'utilisateurs', value: 'valeur', view: 'voir', want: 'voulez',
|
||||
warning: 'avertissement', with: 'avec', you: 'vous', your: 'votre',
|
||||
},
|
||||
hi: {
|
||||
access: 'पहुंच', account: 'खाता', accounts: 'खाते', action: 'क्रिया', actions: 'क्रियाएं', active: 'सक्रिय',
|
||||
add: 'जोड़ें', added: 'जोड़ा गया', address: 'पता', admin: 'प्रशासक', all: 'सभी', amount: 'राशि',
|
||||
and: 'और', apply: 'लागू करें', approved: 'स्वीकृत', available: 'उपलब्ध', back: 'वापस', balance: 'शेष',
|
||||
bank: 'बैंक', barcode: 'बारकोड', brand: 'ब्रांड', brands: 'ब्रांड', cancel: 'रद्द करें', cancelled: 'रद्द',
|
||||
cannot: 'नहीं किया जा सकता', card: 'कार्ड', cart: 'कार्ट', cash: 'नकद', categories: 'श्रेणियां',
|
||||
category: 'श्रेणी', change: 'बदलें', charge: 'शुल्क', charges: 'शुल्क', city: 'शहर', close: 'बंद करें',
|
||||
closed: 'बंद', code: 'कोड', company: 'कंपनी', comparison: 'तुलना', completed: 'पूर्ण', confirm: 'पुष्टि करें',
|
||||
contact: 'संपर्क', country: 'देश', create: 'बनाएं', created: 'बनाया गया', customer: 'ग्राहक', customers: 'ग्राहक',
|
||||
dashboard: 'डैशबोर्ड', date: 'तारीख', delete: 'हटाएं', deleted: 'हटाया गया', delivery: 'डिलीवरी',
|
||||
description: 'विवरण', details: 'विवरण', discount: 'छूट', draft: 'मसौदा', due: 'बकाया', edit: 'संपादित करें',
|
||||
email: 'ईमेल', enter: 'दर्ज करें', error: 'त्रुटि', expense: 'खर्च', expenses: 'खर्च', expired: 'समाप्त',
|
||||
failed: 'विफल', filter: 'फिल्टर', for: 'के लिए', found: 'मिला', from: 'से', gateway: 'गेटवे', gateways: 'गेटवे',
|
||||
goods: 'माल', group: 'समूह', information: 'जानकारी', inventory: 'इन्वेंटरी', invoice: 'चालान', invoices: 'चालान',
|
||||
item: 'आइटम', items: 'आइटम', ledger: 'लेजर', list: 'सूची', loading: 'लोड हो रहा है', login: 'लॉगिन',
|
||||
manage: 'प्रबंधित करें', management: 'प्रबंधन', material: 'सामग्री', method: 'विधि', methods: 'विधियां',
|
||||
mobile: 'मोबाइल', name: 'नाम', new: 'नया', no: 'कोई नहीं', note: 'नोट', notes: 'नोट्स', number: 'नंबर',
|
||||
of: 'का', on: 'पर', open: 'खुला', opening: 'आरंभिक', optional: 'वैकल्पिक', order: 'आदेश',
|
||||
orders: 'आदेश', paid: 'भुगतान किया', password: 'पासवर्ड', payment: 'भुगतान', payments: 'भुगतान',
|
||||
pending: 'लंबित', phone: 'फोन', please: 'कृपया', pos: 'पीओएस', posted: 'पोस्ट किया गया', price: 'कीमत',
|
||||
print: 'प्रिंट', product: 'उत्पाद', purchase: 'खरीद', quantity: 'मात्रा', quotation: 'कोटेशन', quotations: 'कोटेशन',
|
||||
receipt: 'रसीद', receipts: 'रसीदें', received: 'प्राप्त', record: 'रिकॉर्ड', reference: 'संदर्भ', refund: 'रिफंड',
|
||||
rejected: 'अस्वीकृत', request: 'अनुरोध', requests: 'अनुरोध', required: 'आवश्यक', reset: 'रीसेट',
|
||||
return: 'वापसी', returns: 'वापसी', role: 'भूमिका', sale: 'बिक्री', sales: 'बिक्री', save: 'सहेजें',
|
||||
search: 'खोजें', select: 'चुनें', selected: 'चयनित', sent: 'भेजा गया', session: 'सत्र', sessions: 'सत्र',
|
||||
settings: 'सेटिंग्स', sign: 'साइन', state: 'राज्य', status: 'स्थिति', stock: 'स्टॉक', store: 'स्टोर',
|
||||
stores: 'स्टोर', subcategory: 'उपश्रेणी', submit: 'जमा करें', success: 'सफलता', successfully: 'सफलतापूर्वक',
|
||||
supplier: 'आपूर्तिकर्ता', suppliers: 'आपूर्तिकर्ता', summary: 'सारांश', tax: 'कर', taxes: 'कर',
|
||||
terms: 'शर्तें', this: 'यह', to: 'तक', total: 'कुल', transaction: 'लेनदेन', type: 'प्रकार',
|
||||
undone: 'वापस', unit: 'इकाई', units: 'इकाइयां', update: 'अपडेट', updated: 'अपडेट किया गया',
|
||||
upload: 'अपलोड', user: 'उपयोगकर्ता', users: 'उपयोगकर्ता', value: 'मूल्य', view: 'देखें', want: 'चाहते हैं',
|
||||
warning: 'चेतावनी', with: 'साथ', you: 'आप', your: 'आपका',
|
||||
},
|
||||
ms: {
|
||||
access: 'akses', account: 'akaun', accounts: 'akaun', action: 'tindakan', actions: 'tindakan', active: 'aktif',
|
||||
add: 'tambah', added: 'ditambah', address: 'alamat', admin: 'pentadbir', all: 'semua', amount: 'jumlah',
|
||||
and: 'dan', apply: 'guna', approved: 'diluluskan', available: 'tersedia', back: 'kembali', balance: 'baki',
|
||||
bank: 'bank', barcode: 'kod bar', brand: 'jenama', brands: 'jenama', cancel: 'batal', cancelled: 'dibatalkan',
|
||||
cannot: 'tidak boleh', card: 'kad', cart: 'troli', cash: 'tunai', categories: 'kategori', category: 'kategori',
|
||||
change: 'tukar', charge: 'caj', charges: 'caj', city: 'bandar', close: 'tutup', closed: 'ditutup', code: 'kod',
|
||||
company: 'syarikat', comparison: 'perbandingan', completed: 'selesai', confirm: 'sahkan', contact: 'hubungan',
|
||||
country: 'negara', create: 'cipta', created: 'dicipta', customer: 'pelanggan', customers: 'pelanggan',
|
||||
dashboard: 'papan pemuka', date: 'tarikh', delete: 'padam', deleted: 'dipadam', delivery: 'penghantaran',
|
||||
description: 'penerangan', details: 'butiran', discount: 'diskaun', draft: 'draf', due: 'tertunggak',
|
||||
edit: 'sunting', email: 'e-mel', enter: 'masukkan', error: 'ralat', expense: 'perbelanjaan',
|
||||
expenses: 'perbelanjaan', expired: 'tamat tempoh', failed: 'gagal', filter: 'tapis', for: 'untuk',
|
||||
found: 'ditemui', from: 'dari', gateway: 'gerbang', gateways: 'gerbang', goods: 'barang', group: 'kumpulan',
|
||||
information: 'maklumat', inventory: 'inventori', invoice: 'invois', invoices: 'invois', item: 'item',
|
||||
items: 'item', ledger: 'lejar', list: 'senarai', loading: 'memuatkan', login: 'log masuk', manage: 'urus',
|
||||
management: 'pengurusan', material: 'bahan', method: 'kaedah', methods: 'kaedah', mobile: 'mudah alih',
|
||||
name: 'nama', new: 'baharu', no: 'tiada', note: 'nota', notes: 'nota', number: 'nombor', of: 'daripada',
|
||||
on: 'pada', open: 'buka', opening: 'pembukaan', optional: 'pilihan', order: 'pesanan', orders: 'pesanan',
|
||||
paid: 'dibayar', password: 'kata laluan', payment: 'bayaran', payments: 'bayaran', pending: 'tertunda',
|
||||
phone: 'telefon', please: 'sila', pos: 'POS', posted: 'dipos', price: 'harga', print: 'cetak',
|
||||
product: 'produk', purchase: 'pembelian', quantity: 'kuantiti', quotation: 'sebut harga',
|
||||
quotations: 'sebut harga', receipt: 'resit', receipts: 'resit', received: 'diterima', record: 'rekod',
|
||||
reference: 'rujukan', refund: 'bayaran balik', rejected: 'ditolak', request: 'permintaan', requests: 'permintaan',
|
||||
required: 'diperlukan', reset: 'tetapkan semula', return: 'pulangan', returns: 'pulangan', role: 'peranan',
|
||||
sale: 'jualan', sales: 'jualan', save: 'simpan', search: 'cari', select: 'pilih', selected: 'dipilih',
|
||||
sent: 'dihantar', session: 'sesi', sessions: 'sesi', settings: 'tetapan', sign: 'daftar', state: 'negeri',
|
||||
status: 'status', stock: 'stok', store: 'kedai', stores: 'kedai', subcategory: 'subkategori',
|
||||
submit: 'hantar', success: 'berjaya', successfully: 'berjaya', supplier: 'pembekal', suppliers: 'pembekal',
|
||||
summary: 'ringkasan', tax: 'cukai', taxes: 'cukai', terms: 'terma', this: 'ini', to: 'ke',
|
||||
total: 'jumlah', transaction: 'transaksi', type: 'jenis', undone: 'dibuat asal', unit: 'unit',
|
||||
units: 'unit', update: 'kemas kini', updated: 'dikemas kini', upload: 'muat naik', user: 'pengguna',
|
||||
users: 'pengguna', value: 'nilai', view: 'lihat', want: 'mahu', warning: 'amaran', with: 'dengan',
|
||||
you: 'anda', your: 'anda',
|
||||
},
|
||||
};
|
||||
|
||||
const keepUpper = new Set(['API', 'CSV', 'DN', 'GRN', 'GST', 'GSTIN', 'ID', 'MR', 'PDF', 'PO', 'POS', 'QR', 'RFQ', 'SKU', 'SMS', 'UPI', 'VAT']);
|
||||
|
||||
const extraWords = {
|
||||
ar: {
|
||||
abandoned: 'مهجورة', annually: 'سنويا', analyze: 'تحليل', application: 'تطبيق', approval: 'موافقة',
|
||||
assigned: 'معين', authorised: 'مفوض', authorized: 'مفوض', auto: 'تلقائي', avg: 'متوسط', awaiting: 'بانتظار',
|
||||
before: 'قبل', bid: 'عرض', bids: 'عروض', bootstrapping: 'تهيئة', businesses: 'أعمال', calculated: 'محسوب',
|
||||
carts: 'سلات', choose: 'اختر', clear: 'مسح', cleanup: 'تنظيف', column: 'عمود', confidence: 'ثقة',
|
||||
contain: 'تحتوي', counter: 'كاونتر', days: 'أيام', deadline: 'موعد نهائي', demo: 'عرض توضيحي', disc: 'خصم',
|
||||
document: 'مستند', domain: 'نطاق', extraction: 'استخراج', fee: 'رسوم', file: 'ملف', files: 'ملفات',
|
||||
first: 'أول', floor: 'طابق', format: 'تنسيق', free: 'مجاني', generated: 'مولد', get: 'ابدأ',
|
||||
guidelines: 'إرشادات', headers: 'رؤوس', identifier: 'معرف', image: 'صورة', import: 'استيراد',
|
||||
instructions: 'تعليمات', internal: 'داخلي', landmark: 'معلم', last: 'آخر', link: 'رابط', main: 'رئيسي',
|
||||
margin: 'هامش', me: 'تذكرني', minimum: 'الحد الأدنى', month: 'شهر', monthly: 'شهريا', more: 'المزيد',
|
||||
multiple: 'متعددة', near: 'قرب', never: 'أبدا', numbers: 'أرقام', off: 'تقريب', older: 'أقدم',
|
||||
online: 'متصل', pagination: 'ترقيم الصفحات', permanent: 'دائم', physical: 'فعلي', pin: 'رمز', platform: 'منصة',
|
||||
policies: 'سياسات', postcode: 'الرمز البريدي', preview: 'معاينة', profit: 'ربح', published: 'منشور',
|
||||
quick: 'سريع', qty: 'الكمية', recommended: 'موصى به', registration: 'تسجيل', remember: 'تذكرني',
|
||||
remove: 'إزالة', resetting: 'إعادة التعيين', response: 'استجابة', retention: 'احتفاظ', retry: 'إعادة المحاولة',
|
||||
row: 'صف', sac: 'SAC', saved: 'محفوظ', secret: 'سر', selection: 'اختيار', sending: 'جار الإرسال',
|
||||
share: 'مشاركة', should: 'يجب', signature: 'توقيع', source: 'مصدر', started: 'ابدأ', subtotal: 'المجموع الفرعي',
|
||||
supports: 'يدعم', than: 'من', title: 'عنوان', today: 'اليوم', trail: 'سجل', transactions: 'معاملات',
|
||||
trial: 'تجربة', until: 'حتى', valid: 'صالح', verifying: 'جار التحقق', verify: 'تحقق', watch: 'شاهد',
|
||||
webhook: 'Webhook', website: 'موقع إلكتروني', weekly: 'أسبوعيا', wipe: 'مسح', yesterday: 'أمس', zip: 'ZIP',
|
||||
},
|
||||
fr: {
|
||||
abandoned: 'abandonnes', annually: 'annuellement', analyze: 'analyser', application: 'application',
|
||||
approval: 'approbation', assigned: 'assigne', authorised: 'signataire autorise', authorized: 'autorise',
|
||||
auto: 'auto', avg: 'moy.', awaiting: 'en attente', before: 'avant', bid: 'offre', bids: 'offres',
|
||||
bootstrapping: 'demarrage', businesses: 'entreprises', calculated: 'calcule', carts: 'paniers',
|
||||
choose: 'choisir', clear: 'effacer', cleanup: 'nettoyage', column: 'colonne', confidence: 'confiance',
|
||||
contain: 'contenir', counter: 'comptoir', days: 'jours', deadline: 'echeance', demo: 'demo', disc: 'rem.',
|
||||
document: 'document', domain: 'domaine', extraction: 'extraction', fee: 'frais', file: 'fichier',
|
||||
files: 'fichiers', first: 'premiere', floor: 'etage', format: 'format', free: 'gratuit', generated: 'genere',
|
||||
get: 'commencer', guidelines: 'consignes', headers: 'en-tetes', identifier: 'identifiant', image: 'image',
|
||||
instructions: 'instructions', internal: 'interne', landmark: 'repere', last: 'dernier', link: 'lien',
|
||||
main: 'principal', margin: 'marge', me: 'moi', minimum: 'minimum', month: 'mois', monthly: 'mensuel',
|
||||
more: 'plus', multiple: 'multiples', near: 'pres de', never: 'jamais', numbers: 'nombres', off: 'arrondi',
|
||||
older: 'plus anciens', online: 'en ligne', pagination: 'pagination', permanent: 'permanent', physical: 'physique',
|
||||
pin: 'PIN', platform: 'plateforme', policies: 'politiques', postcode: 'code postal', preview: 'apercu',
|
||||
profit: 'benefice', published: 'publie', quick: 'rapide', qty: 'qte', recommended: 'recommande',
|
||||
registration: 'inscription', remember: 'se souvenir', remove: 'supprimer', resetting: 'reinitialisation',
|
||||
response: 'reponse', retention: 'conservation', retry: 'reessayer', row: 'ligne', saved: 'enregistre',
|
||||
secret: 'secret', selection: 'selection', sending: 'envoi', share: 'partager', should: 'doit',
|
||||
signature: 'signature', source: 'source', started: 'demarrer', subtotal: 'sous-total', supports: 'prend en charge',
|
||||
than: 'que', title: 'titre', today: 'aujourd hui', trail: 'piste', transactions: 'transactions',
|
||||
trial: 'essai', until: 'jusqu au', valid: 'valide', verifying: 'verification', verify: 'verifier',
|
||||
watch: 'regarder', webhook: 'secret webhook', website: 'site web', weekly: 'hebdomadaire', wipe: 'effacement',
|
||||
yesterday: 'hier', zip: 'ZIP',
|
||||
},
|
||||
hi: {
|
||||
abandoned: 'छोड़े गए', annually: 'वार्षिक', analyze: 'विश्लेषण करें', application: 'एप्लिकेशन',
|
||||
approval: 'अनुमोदन', assigned: 'असाइन किया गया', authorised: 'अधिकृत', authorized: 'अधिकृत',
|
||||
auto: 'स्वतः', avg: 'औसत', awaiting: 'प्रतीक्षा में', before: 'पहले', bid: 'बोली', bids: 'बोलियां',
|
||||
bootstrapping: 'आरंभ हो रहा है', businesses: 'व्यवसाय', calculated: 'गणना किया गया', carts: 'कार्ट',
|
||||
choose: 'चुनें', clear: 'साफ करें', cleanup: 'सफाई', column: 'कॉलम', confidence: 'विश्वास',
|
||||
contain: 'होना चाहिए', counter: 'काउंटर', days: 'दिन', deadline: 'अंतिम तिथि', demo: 'डेमो', disc: 'छूट',
|
||||
document: 'दस्तावेज', domain: 'डोमेन', extraction: 'निकासी', fee: 'शुल्क', file: 'फाइल', files: 'फाइलें',
|
||||
first: 'पहली', floor: 'मंजिल', format: 'फॉर्मेट', free: 'मुफ्त', generated: 'जनरेट किया गया',
|
||||
get: 'शुरू करें', guidelines: 'दिशानिर्देश', headers: 'हेडर', identifier: 'पहचानकर्ता', image: 'छवि',
|
||||
import: 'आयात', instructions: 'निर्देश', internal: 'आंतरिक', landmark: 'लैंडमार्क', last: 'पिछला',
|
||||
link: 'लिंक', main: 'मुख्य', margin: 'मार्जिन', me: 'मुझे', minimum: 'न्यूनतम', month: 'महीना',
|
||||
monthly: 'मासिक', more: 'अधिक', multiple: 'कई', near: 'पास', never: 'कभी नहीं', numbers: 'नंबर',
|
||||
off: 'राउंड ऑफ', older: 'से पुराने', online: 'ऑनलाइन', pagination: 'पेजिनेशन', permanent: 'स्थायी',
|
||||
physical: 'भौतिक', pin: 'पिन', platform: 'प्लेटफॉर्म', policies: 'नीतियां', postcode: 'पोस्टकोड',
|
||||
preview: 'पूर्वावलोकन', profit: 'लाभ', published: 'प्रकाशित', quick: 'त्वरित', qty: 'मात्रा',
|
||||
recommended: 'अनुशंसित', registration: 'पंजीकरण', remember: 'याद रखें', remove: 'हटाएं',
|
||||
resetting: 'रीसेट हो रहा है', response: 'प्रतिक्रिया', retention: 'रिटेंशन', retry: 'फिर कोशिश करें',
|
||||
row: 'पंक्ति', saved: 'सहेजा गया', secret: 'सीक्रेट', selection: 'चयन', sending: 'भेजा जा रहा है',
|
||||
share: 'साझा करें', should: 'होना चाहिए', signature: 'हस्ताक्षर', source: 'स्रोत', started: 'शुरू करें',
|
||||
subtotal: 'उप-योग', supports: 'समर्थित', than: 'से', title: 'शीर्षक', today: 'आज', trail: 'ट्रेल',
|
||||
transactions: 'लेनदेन', trial: 'ट्रायल', until: 'तक', valid: 'मान्य', verifying: 'सत्यापित हो रहा है',
|
||||
verify: 'सत्यापित करें', watch: 'देखें', webhook: 'वेबहुक', website: 'वेबसाइट', weekly: 'साप्ताहिक',
|
||||
wipe: 'वाइप', yesterday: 'कल', zip: 'ZIP',
|
||||
},
|
||||
ms: {
|
||||
abandoned: 'terbiar', annually: 'tahunan', analyze: 'analisis', application: 'aplikasi', approval: 'kelulusan',
|
||||
assigned: 'ditugaskan', authorised: 'dibenarkan', authorized: 'dibenarkan', auto: 'auto', avg: 'purata',
|
||||
awaiting: 'menunggu', before: 'sebelum', bid: 'bidaan', bids: 'bidaan', bootstrapping: 'memulakan',
|
||||
businesses: 'perniagaan', calculated: 'dikira', carts: 'troli', choose: 'pilih', clear: 'kosongkan',
|
||||
cleanup: 'pembersihan', column: 'lajur', confidence: 'keyakinan', contain: 'mengandungi', counter: 'kaunter',
|
||||
days: 'hari', deadline: 'tarikh akhir', demo: 'demo', disc: 'diskaun', document: 'dokumen',
|
||||
domain: 'domain', extraction: 'pengekstrakan', fee: 'fi', file: 'fail', files: 'fail', first: 'pertama',
|
||||
floor: 'tingkat', format: 'format', free: 'percuma', generated: 'dijana', get: 'mula',
|
||||
guidelines: 'panduan', headers: 'tajuk lajur', identifier: 'pengenal', image: 'imej', instructions: 'arahan',
|
||||
internal: 'dalaman', landmark: 'mercu tanda', last: 'terakhir', link: 'pautan', main: 'utama',
|
||||
margin: 'margin', me: 'saya', minimum: 'minimum', month: 'bulan', monthly: 'bulanan', more: 'lagi',
|
||||
multiple: 'berbilang', near: 'berhampiran', never: 'tidak pernah', numbers: 'nombor', off: 'bundar',
|
||||
older: 'lebih lama', online: 'dalam talian', pagination: 'halaman', permanent: 'kekal', physical: 'fizikal',
|
||||
pin: 'PIN', platform: 'platform', policies: 'polisi', postcode: 'poskod', preview: 'pratonton',
|
||||
profit: 'untung', published: 'diterbitkan', quick: 'pantas', qty: 'kuantiti', recommended: 'disyorkan',
|
||||
registration: 'pendaftaran', remember: 'ingat', remove: 'buang', resetting: 'menetapkan semula',
|
||||
response: 'respons', retention: 'pengekalan', retry: 'cuba lagi', row: 'baris', saved: 'disimpan',
|
||||
secret: 'rahsia', selection: 'pilihan', sending: 'menghantar', share: 'kongsi', should: 'harus',
|
||||
signature: 'tandatangan', source: 'sumber', started: 'bermula', subtotal: 'subtotal', supports: 'menyokong',
|
||||
than: 'daripada', title: 'tajuk', today: 'hari ini', trail: 'jejak', transactions: 'transaksi',
|
||||
trial: 'percubaan', until: 'hingga', valid: 'sah', verifying: 'mengesahkan', verify: 'sahkan',
|
||||
watch: 'tonton', webhook: 'webhook', website: 'laman web', weekly: 'mingguan', wipe: 'hapus',
|
||||
yesterday: 'semalam', zip: 'ZIP',
|
||||
},
|
||||
};
|
||||
|
||||
for (const lang of Object.keys(extraWords)) {
|
||||
Object.assign(wordMaps[lang], extraWords[lang]);
|
||||
}
|
||||
|
||||
const moreWords = {
|
||||
ar: {
|
||||
actual: 'فعلي', another: 'آخر', apartment: 'شقة', applied: 'مطبق', attached: 'مرفق', authentication: 'مصادقة',
|
||||
breakdown: 'تفصيل', channels: 'قنوات', check: 'فحص', closing: 'إغلاق', code: 'رمز', compared: 'مقارن',
|
||||
converted: 'محول', counters: 'كاونترات', damaged: 'تالف', discrepancies: 'فروقات', exceeded: 'تم التجاوز',
|
||||
export: 'تصدير', final: 'نهائي', finalize: 'إنهاء', finalized: 'منتهي', gross: 'إجمالي', hash: 'رقم',
|
||||
identity: 'هوية', integrated: 'متكامل', invitation: 'دعوة', invited: 'مدعو', taxable: 'خاضع للضريبة',
|
||||
key: 'مفتاح', lifecycle: 'دورة الحياة', line: 'سطر', lines: 'أسطر', marked: 'تم وضع علامة', match: 'متطابقة',
|
||||
mode: 'وضع', net: 'صافي', notice: 'إشعار', opened: 'تم الفتح', otp: 'OTP', outstanding: 'مستحق',
|
||||
owner: 'مالك', paying: 'جار الدفع', permission: 'صلاحيات', permissions: 'صلاحيات', portal: 'بوابة',
|
||||
prepayment: 'دفعة مقدمة', proof: 'إثبات', protocols: 'بروتوكولات', refundable: 'قابل للاسترداد',
|
||||
recording: 'تسجيل', roles: 'أدوار', secret: 'سر', send: 'إرسال', shipping: 'شحن', suite: 'جناح',
|
||||
swift: 'SWIFT', terminal: 'طرفية', unit: 'وحدة', variance: 'تباين', verification: 'تحقق',
|
||||
},
|
||||
fr: {
|
||||
actual: 'reel', another: 'autre', apartment: 'appartement', applied: 'applique', attached: 'jointe',
|
||||
authentication: 'authentification', breakdown: 'repartition', channels: 'canaux', check: 'controle',
|
||||
closing: 'cloture', code: 'code', compared: 'compare', converted: 'converti', counters: 'comptoirs',
|
||||
damaged: 'endommage', discrepancies: 'ecarts', exceeded: 'depassee', export: 'export', final: 'final',
|
||||
finalize: 'finaliser', finalized: 'finalise', gross: 'brut', identity: 'identite', integrated: 'integre',
|
||||
invitation: 'invitation', taxable: 'taxable', key: 'cle', lifecycle: 'cycle de vie', line: 'ligne',
|
||||
lines: 'lignes', marked: 'marque', match: 'correspondent', mode: 'mode', net: 'net', notice: 'avis',
|
||||
opened: 'ouvert', otp: 'OTP', outstanding: 'restant', owner: 'responsable', paying: 'paiement',
|
||||
permission: 'autorisations', permissions: 'autorisations', portal: 'portail', prepayment: 'acompte',
|
||||
proof: 'preuve', protocols: 'protocoles', refundable: 'remboursable', recording: 'enregistrement',
|
||||
roles: 'roles', secret: 'secret', send: 'envoyer', shipping: 'expedition', suite: 'suite', swift: 'SWIFT',
|
||||
terminal: 'terminal', unit: 'unite', variance: 'ecart', verification: 'verification',
|
||||
},
|
||||
hi: {
|
||||
actual: 'वास्तविक', another: 'दूसरा', apartment: 'अपार्टमेंट', applied: 'लागू', attached: 'संलग्न',
|
||||
authentication: 'प्रमाणीकरण', breakdown: 'विवरण', channels: 'चैनल', check: 'जांच', closing: 'समापन',
|
||||
code: 'कोड', compared: 'तुलना किया गया', converted: 'बदला गया', counters: 'काउंटर', damaged: 'क्षतिग्रस्त',
|
||||
discrepancies: 'अंतर', exceeded: 'सीमा पार', export: 'निर्यात', final: 'अंतिम', finalize: 'अंतिम करें',
|
||||
finalized: 'अंतिम किया गया', gross: 'सकल', identity: 'पहचान', integrated: 'एकीकृत', invitation: 'आमंत्रण',
|
||||
taxable: 'कर योग्य', key: 'कुंजी', lifecycle: 'जीवनचक्र', line: 'लाइन', lines: 'लाइनें',
|
||||
marked: 'चिह्नित', match: 'मिलते हैं', mode: 'मोड', net: 'नेट', notice: 'सूचना', opened: 'खोला गया',
|
||||
otp: 'OTP', outstanding: 'बकाया', owner: 'स्वामी', paying: 'भुगतान हो रहा है', permission: 'अनुमति',
|
||||
permissions: 'अनुमतियां', portal: 'पोर्टल', prepayment: 'अग्रिम भुगतान', proof: 'प्रमाण',
|
||||
protocols: 'प्रोटोकॉल', refundable: 'रिफंड योग्य', recording: 'रिकॉर्डिंग', roles: 'भूमिकाएं',
|
||||
secret: 'सीक्रेट', send: 'भेजें', shipping: 'शिपिंग', suite: 'सुइट', swift: 'SWIFT', terminal: 'टर्मिनल',
|
||||
unit: 'इकाई', variance: 'अंतर', verification: 'सत्यापन',
|
||||
},
|
||||
ms: {
|
||||
actual: 'sebenar', another: 'lain', apartment: 'apartmen', applied: 'digunakan', attached: 'dilampirkan',
|
||||
authentication: 'pengesahan', breakdown: 'pecahan', channels: 'saluran', check: 'semakan',
|
||||
closing: 'penutupan', code: 'kod', compared: 'dibandingkan', converted: 'ditukar', counters: 'kaunter',
|
||||
damaged: 'rosak', discrepancies: 'percanggahan', exceeded: 'melebihi had', export: 'eksport',
|
||||
final: 'akhir', finalize: 'muktamadkan', finalized: 'dimuktamadkan', gross: 'kasar', identity: 'identiti',
|
||||
integrated: 'bersepadu', invitation: 'jemputan', taxable: 'boleh dicukai', key: 'kunci',
|
||||
lifecycle: 'kitaran hayat', line: 'baris', lines: 'baris', marked: 'ditanda', match: 'sepadan',
|
||||
mode: 'mod', net: 'bersih', notice: 'notis', opened: 'dibuka', otp: 'OTP', outstanding: 'tertunggak',
|
||||
owner: 'pemilik', paying: 'membayar', permission: 'kebenaran', permissions: 'kebenaran',
|
||||
portal: 'portal', prepayment: 'prabayaran', proof: 'bukti', protocols: 'protokol',
|
||||
refundable: 'boleh dibayar balik', recording: 'rakaman', roles: 'peranan', secret: 'rahsia',
|
||||
send: 'hantar', shipping: 'penghantaran', suite: 'suite', swift: 'SWIFT', terminal: 'terminal',
|
||||
unit: 'unit', variance: 'varians', verification: 'pengesahan',
|
||||
},
|
||||
};
|
||||
|
||||
for (const lang of Object.keys(moreWords)) {
|
||||
Object.assign(wordMaps[lang], moreWords[lang]);
|
||||
}
|
||||
|
||||
const applyCase = (source, translated) => {
|
||||
if (keepUpper.has(source.toUpperCase())) return source.toUpperCase();
|
||||
if (source === source.toUpperCase() && source.length > 1) return translated.toUpperCase();
|
||||
if (/^[A-Z]/.test(source) && /^[a-z]/.test(translated)) {
|
||||
return translated.charAt(0).toUpperCase() + translated.slice(1);
|
||||
}
|
||||
return translated;
|
||||
};
|
||||
|
||||
const translateLoose = (value, lang) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
if (exact[lang]?.[value]) return exact[lang][value];
|
||||
|
||||
const placeholders = [];
|
||||
let work = value.replace(/\{\{[^}]+\}\}/g, (match) => {
|
||||
const token = `__PH_${placeholders.length}__`;
|
||||
placeholders.push(match);
|
||||
return token;
|
||||
});
|
||||
|
||||
const map = wordMaps[lang];
|
||||
work = work.replace(/[A-Za-z][A-Za-z']*/g, (word) => {
|
||||
if (/^__PH_\d+__$/.test(word)) return word;
|
||||
const translated = map[word.toLowerCase()];
|
||||
return translated ? applyCase(word, translated) : word;
|
||||
});
|
||||
|
||||
placeholders.forEach((placeholder, index) => {
|
||||
work = work.replace(`__PH_${index}__`, placeholder);
|
||||
});
|
||||
|
||||
return work;
|
||||
};
|
||||
|
||||
for (const lang of langs) {
|
||||
const file = path.join(localeDir, `${lang}.json`);
|
||||
const locale = readJson(file);
|
||||
const localeFlat = flatten(locale);
|
||||
|
||||
const translatedByEnglishValue = new Map();
|
||||
for (const [key, enValue] of Object.entries(enFlat)) {
|
||||
const localeValue = localeFlat[key];
|
||||
if (typeof enValue !== 'string' || typeof localeValue !== 'string') continue;
|
||||
if (localeValue !== enValue) translatedByEnglishValue.set(enValue, localeValue);
|
||||
}
|
||||
|
||||
let changed = 0;
|
||||
for (const [key, enValue] of Object.entries(enFlat)) {
|
||||
if (typeof enValue !== 'string') continue;
|
||||
const current = localeFlat[key];
|
||||
if (current !== undefined && current !== enValue) continue;
|
||||
|
||||
const replacement = translatedByEnglishValue.get(enValue) ?? translateLoose(enValue, lang);
|
||||
if (replacement !== current) {
|
||||
setDeep(locale, key, replacement);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(file, `${JSON.stringify(locale, null, 2)}\n`, 'utf8');
|
||||
console.log(`${lang}: backfilled ${changed} entries`);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const features = [
|
||||
{ name: 'attribute-groups', Name: 'AttributeGroup' },
|
||||
{ name: 'scopes', Name: 'Scope' },
|
||||
{ name: 'assets', Name: 'Asset' },
|
||||
{ name: 'asset-types', Name: 'AssetType' },
|
||||
{ name: 'asset-families', Name: 'AssetFamily' },
|
||||
{ name: 'imports', Name: 'Import' },
|
||||
{ name: 'workflow', Name: 'Workflow' },
|
||||
{ name: 'channels', Name: 'Channel' },
|
||||
{ name: 'integrations', Name: 'Integration' },
|
||||
{ name: 'users', Name: 'User' },
|
||||
{ name: 'reports', Name: 'Report' },
|
||||
{ name: 'settings', Name: 'Setting' },
|
||||
];
|
||||
|
||||
const basePath = path.join(__dirname, '../src/features');
|
||||
|
||||
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
const camelCase = (s) => s.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
|
||||
features.forEach(({ name, Name }) => {
|
||||
const camelName = camelCase(name);
|
||||
const featurePath = path.join(basePath, name);
|
||||
|
||||
if (!fs.existsSync(featurePath)) fs.mkdirSync(featurePath, { recursive: true });
|
||||
['api', 'hook', 'pages', 'routes', 'services', 'types', 'validation'].forEach(dir => {
|
||||
const dirPath = path.join(featurePath, dir);
|
||||
if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
|
||||
});
|
||||
|
||||
// types
|
||||
fs.writeFileSync(path.join(featurePath, `types/${name}.types.ts`), `export interface ${Name} {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type ${Name}CreateRequest = Omit<${Name}, 'id' | 'createdAt'>;
|
||||
export type ${Name}UpdateRequest = Partial<${Name}CreateRequest>;
|
||||
`);
|
||||
|
||||
// validation
|
||||
fs.writeFileSync(path.join(featurePath, `validation/${name}.schema.ts`), `import type { ${Name}CreateRequest } from '../types/${name}.types';
|
||||
export const validate${Name} = (data: Partial<${Name}CreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = '${Name} name is required';
|
||||
return errors;
|
||||
};
|
||||
`);
|
||||
|
||||
// service
|
||||
fs.writeFileSync(path.join(featurePath, `services/${name}.service.ts`), `import type { ${Name}, ${Name}CreateRequest, ${Name}UpdateRequest } from '../types/${name}.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_${name.replace('-', '_')}';
|
||||
|
||||
const getStored = (): ${Name}[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: ${Name}[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const ${camelName}Service = {
|
||||
getAll: async (): Promise<${Name}[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<${Name} | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: ${Name}CreateRequest): Promise<${Name}> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: ${Name} = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: ${Name}UpdateRequest): Promise<${Name}> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
`);
|
||||
|
||||
// api
|
||||
fs.writeFileSync(path.join(featurePath, `api/${name}.api.ts`), `import { ${camelName}Service } from '../services/${name}.service';
|
||||
export const ${camelName}Api = ${camelName}Service;
|
||||
`);
|
||||
|
||||
// hook
|
||||
fs.writeFileSync(path.join(featurePath, `hook/use${Name}.ts`), `import { useState, useCallback } from 'react';
|
||||
import { ${camelName}Service } from '../services/${name}.service';
|
||||
import type { ${Name}, ${Name}CreateRequest, ${Name}UpdateRequest } from '../types/${name}.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const use${Name} = () => {
|
||||
const [items, setItems] = useState<${Name}[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await ${camelName}Service.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: ${Name}CreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await ${camelName}Service.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('${Name} created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: ${Name}UpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await ${camelName}Service.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('${Name} updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await ${camelName}Service.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('${Name} deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
`);
|
||||
|
||||
// pages - List
|
||||
fs.writeFileSync(path.join(featurePath, `pages/${Name}List.tsx`), `import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { use${Name} } from "../hook/use${Name}";
|
||||
import type { ${Name} } from "../types/${name}.types";
|
||||
|
||||
export default function ${Name}List() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = use${Name}();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: ${Name}) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: ${Name}) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">${Name}s</h1>
|
||||
<p className="text-sm text-gray-500">Manage your ${name.replace('-', ' ')}</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create ${Name}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// pages - New
|
||||
fs.writeFileSync(path.join(featurePath, `pages/New${Name}.tsx`), `import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { use${Name} } from "../hook/use${Name}";
|
||||
import { validate${Name} } from "../validation/${name}.schema";
|
||||
import { ${camelName}Service } from "../services/${name}.service";
|
||||
import type { ${Name}CreateRequest } from "../types/${name}.types";
|
||||
|
||||
export default function New${Name}() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = use${Name}();
|
||||
|
||||
const [form, setForm] = useState<Partial<${Name}CreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
${camelName}Service.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validate${Name}(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as ${Name}CreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} ${Name}</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
`);
|
||||
|
||||
// routes
|
||||
fs.writeFileSync(path.join(featurePath, `routes/${name}.routes.tsx`), `import { Routes, Route } from 'react-router-dom';
|
||||
import ${Name}List from '../pages/${Name}List';
|
||||
import New${Name} from '../pages/New${Name}';
|
||||
|
||||
export const ${Name}Routes = () => (
|
||||
<Routes>
|
||||
<Route index element={<${Name}List />} />
|
||||
<Route path="new" element={<New${Name} />} />
|
||||
<Route path=":id/edit" element={<New${Name} />} />
|
||||
</Routes>
|
||||
);
|
||||
`);
|
||||
|
||||
// index
|
||||
fs.writeFileSync(path.join(featurePath, `index.ts`), `export * from './types/${name}.types';
|
||||
export * from './services/${name}.service';
|
||||
export * from './hook/use${Name}';
|
||||
export * from './routes/${name}.routes';
|
||||
`);
|
||||
});
|
||||
|
||||
console.log('Scaffolding complete!');
|
||||
@@ -0,0 +1,57 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:5000';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const apiClient = {
|
||||
get: async <T>(url: string, params?: any): Promise<T> => {
|
||||
const response = await axiosInstance.get<T>(url, { params });
|
||||
return response.data;
|
||||
},
|
||||
post: async <T>(url: string, data?: unknown): Promise<T> => {
|
||||
const response = await axiosInstance.post<T>(url, data);
|
||||
return response.data;
|
||||
},
|
||||
put: async <T>(url: string, data?: unknown): Promise<T> => {
|
||||
const response = await axiosInstance.put<T>(url, data);
|
||||
return response.data;
|
||||
},
|
||||
delete: async <T>(url: string, params?: any): Promise<T> => {
|
||||
const response = await axiosInstance.delete<T>(url, { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export { axiosInstance };
|
||||
export default apiClient;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export type InfoCardVariant =
|
||||
| "default"
|
||||
| "green"
|
||||
| "blue"
|
||||
| "amber"
|
||||
| "red"
|
||||
| "purple";
|
||||
|
||||
export interface InfoCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: ReactNode;
|
||||
trend?: string;
|
||||
trendDirection?: "up" | "down" | "neutral";
|
||||
subtitle?: string;
|
||||
variant?: InfoCardVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CARD_STYLES: Record<InfoCardVariant, string> = {
|
||||
default: "bg-gray-50 border-gray-200",
|
||||
green: "bg-green-50 border-green-200",
|
||||
blue: "bg-blue-50 border-blue-200",
|
||||
amber: "bg-amber-50 border-amber-200",
|
||||
red: "bg-red-50 border-red-200",
|
||||
purple: "bg-purple-50 border-purple-200",
|
||||
};
|
||||
|
||||
const ICON_BG_STYLES: Record<InfoCardVariant, string> = {
|
||||
default: "bg-gray-500",
|
||||
green: "bg-green-500",
|
||||
blue: "bg-blue-500",
|
||||
amber: "bg-amber-500",
|
||||
red: "bg-red-500",
|
||||
purple: "bg-purple-500",
|
||||
};
|
||||
|
||||
const VALUE_STYLES: Record<InfoCardVariant, string> = {
|
||||
default: "text-gray-900",
|
||||
green: "text-green-800",
|
||||
blue: "text-blue-800",
|
||||
amber: "text-amber-800",
|
||||
red: "text-red-800",
|
||||
purple: "text-purple-800",
|
||||
};
|
||||
|
||||
const LABEL_STYLES: Record<InfoCardVariant, string> = {
|
||||
default: "text-gray-500",
|
||||
green: "text-green-600",
|
||||
blue: "text-blue-600",
|
||||
amber: "text-amber-600",
|
||||
red: "text-red-600",
|
||||
purple: "text-purple-600",
|
||||
};
|
||||
|
||||
const TREND_STYLES: Record<string, string> = {
|
||||
up: "text-green-600",
|
||||
down: "text-red-500",
|
||||
neutral: "text-gray-500",
|
||||
};
|
||||
|
||||
export function InfoCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
trendDirection = "up",
|
||||
subtitle,
|
||||
variant = "default",
|
||||
className = "",
|
||||
}: InfoCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-xl p-4 flex items-center gap-4 border shadow-sm
|
||||
${CARD_STYLES[variant]} ${className}
|
||||
`}
|
||||
>
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-xl text-white shadow-sm shrink-0 ${ICON_BG_STYLES[variant]}`}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`text-2xl font-bold leading-tight ${VALUE_STYLES[variant]}`}>
|
||||
{value}
|
||||
</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`
|
||||
text-xs font-semibold flex items-center gap-0.5
|
||||
${TREND_STYLES[trendDirection]}
|
||||
`}
|
||||
>
|
||||
{trendDirection === "up" && "↗"}
|
||||
{trendDirection === "down" && "↘"}
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${LABEL_STYLES[variant]}`}>
|
||||
{label}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoCardGridProps {
|
||||
children: ReactNode;
|
||||
cols?: 1 | 2 | 3 | 4 | 5;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GRID_COLS: Record<number, string> = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-1 sm:grid-cols-2",
|
||||
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
|
||||
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
||||
5: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5",
|
||||
};
|
||||
|
||||
export function InfoCardGrid({ children, cols = 4, className = "" }: InfoCardGridProps) {
|
||||
return (
|
||||
<div className={`grid gap-4 ${GRID_COLS[cols]} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { type ReactNode, useState, useMemo } from "react";
|
||||
import { ChevronUp, ChevronDown, MoreVertical } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface TableColumn<T = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
width?: string;
|
||||
render?: (value: any, row: T) => ReactNode;
|
||||
}
|
||||
|
||||
export interface TableProps<T = any> {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
onRowClick?: (row: T) => void;
|
||||
selectable?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
onSelectionChange?: (selected: Set<string>) => void;
|
||||
actions?: (row: T) => ReactNode;
|
||||
rowIdKey?: keyof T;
|
||||
// Pagination
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any> = any>({
|
||||
columns,
|
||||
data,
|
||||
onRowClick,
|
||||
selectable = false,
|
||||
selectedIds = new Set(),
|
||||
onSelectionChange,
|
||||
actions,
|
||||
rowIdKey = "id" as keyof T,
|
||||
pageSize = 10,
|
||||
}: TableProps<T>) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// Sorting logic
|
||||
const handleSort = (key: string) => {
|
||||
if (sortColumn === key) {
|
||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortColumn(key);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortColumn) return data;
|
||||
const sorted = [...data];
|
||||
sorted.sort((a, b) => {
|
||||
const aVal = a[sortColumn];
|
||||
const bVal = b[sortColumn];
|
||||
if (aVal === undefined || aVal === null) return 1;
|
||||
if (bVal === undefined || bVal === null) return -1;
|
||||
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
|
||||
const aStr = String(aVal).toLowerCase();
|
||||
const bStr = String(bVal).toLowerCase();
|
||||
if (aStr < bStr) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aStr > bStr) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}, [data, sortColumn, sortDirection]);
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize) || 1;
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return sortedData.slice(start, start + pageSize);
|
||||
}, [sortedData, currentPage, pageSize]);
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectRow = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!onSelectionChange) return;
|
||||
const newSelected = new Set(selectedIds);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (e.target.checked) {
|
||||
const allIds = new Set(paginatedData.map((row) => String(row[rowIdKey])));
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = paginatedData.length > 0 && paginatedData.every((row) => selectedIds.has(String(row[rowIdKey])));
|
||||
const isSomeSelected = paginatedData.some((row) => selectedIds.has(String(row[rowIdKey]))) && !isAllSelected;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
{selectable && (
|
||||
<th className="w-12 px-5 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
checked={isAllSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isSomeSelected;
|
||||
}}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
onClick={() => column.sortable && handleSort(column.key)}
|
||||
className={`px-5 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider ${
|
||||
column.sortable ? "cursor-pointer select-none hover:bg-gray-100" : ""
|
||||
} ${column.width || ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{column.label}</span>
|
||||
{column.sortable && (
|
||||
<div className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={`w-3.5 h-3.5 -mb-1 transition-colors ${
|
||||
sortColumn === column.key && sortDirection === "asc"
|
||||
? "text-purple-600 font-bold"
|
||||
: "text-gray-350"
|
||||
}`}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-colors ${
|
||||
sortColumn === column.key && sortDirection === "desc"
|
||||
? "text-purple-600 font-bold"
|
||||
: "text-gray-350"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{actions && (
|
||||
<th className="w-16 px-5 py-3 text-right">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0)}
|
||||
className="px-5 py-12 text-center text-sm text-gray-400 font-medium"
|
||||
>
|
||||
No records found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row, index) => {
|
||||
const id = String(row[rowIdKey]);
|
||||
const isSelected = selectedIds.has(id);
|
||||
return (
|
||||
<tr
|
||||
key={id || index}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-gray-50/70 transition-colors ${
|
||||
onRowClick ? "cursor-pointer" : ""
|
||||
} ${isSelected ? "bg-purple-50/20" : ""}`}
|
||||
>
|
||||
{selectable && (
|
||||
<td className="px-5 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
checked={isSelected}
|
||||
onClick={(e) => handleSelectRow(id, e)}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 py-3.5 text-sm text-gray-700 whitespace-nowrap">
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key] ?? "—"}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{actions(row)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-100 pt-4 px-1">
|
||||
<div className="text-xs text-gray-500">
|
||||
Showing Page <strong>{currentPage}</strong> of <strong>{totalPages}</strong> (<strong>{data.length}</strong> total records)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,52 +30,7 @@ interface NavItem {
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/dashboard" },
|
||||
|
||||
// Products - Most Important
|
||||
{
|
||||
icon: Package,
|
||||
label: "Products",
|
||||
href: "/products",
|
||||
badge: "12.4k",
|
||||
permissionNode: "products.items"
|
||||
},
|
||||
|
||||
{
|
||||
icon: FolderTree,
|
||||
label: "Product Families",
|
||||
href: "/families",
|
||||
permissionNode: "products.families"
|
||||
},
|
||||
|
||||
{ icon: Grid3x3, label: "Variants", href: "/variants" },
|
||||
{ icon: Tag, label: "Categories", href: "/categories" },
|
||||
|
||||
{
|
||||
icon: Layers,
|
||||
label: "Attribute Management",
|
||||
href: "/attributes",
|
||||
children: [
|
||||
{ icon: Layers, label: "Attributes", href: "/attributes" },
|
||||
{ icon: Grid3x3, label: "Attribute Groups", href: "/attribute-groups" }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
icon: Database,
|
||||
label: "Master Data",
|
||||
href: "/master-data",
|
||||
children: [
|
||||
{ icon: Ruler, label: "Units", href: "/units" },
|
||||
{ icon: Award, label: "Brands", href: "/brands" },
|
||||
]
|
||||
},
|
||||
|
||||
{ icon: Upload, label: "Supplier Imports", href: "/imports", badge: "3" },
|
||||
{ icon: Workflow, label: "Workflow & Approvals", href: "/workflow", badge: "8" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
];
|
||||
import { sidebarConfig as navItems } from "../../routes/sidebar.config";
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { assetFamiliesService } from '../services/asset-families.service';
|
||||
export const assetFamiliesApi = assetFamiliesService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { assetFamiliesService } from '../services/asset-families.service';
|
||||
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useAssetFamily = () => {
|
||||
const [items, setItems] = useState<AssetFamily[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await assetFamiliesService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: AssetFamilyCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await assetFamiliesService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('AssetFamily created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: AssetFamilyUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await assetFamiliesService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('AssetFamily updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await assetFamiliesService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('AssetFamily deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/asset-families.types';
|
||||
export * from './services/asset-families.service';
|
||||
export * from './hook/useAssetFamily';
|
||||
export * from './routes/asset-families.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useAssetFamily } from "../hook/useAssetFamily";
|
||||
import type { AssetFamily } from "../types/asset-families.types";
|
||||
|
||||
export default function AssetFamilyList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useAssetFamily();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: AssetFamily) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: AssetFamily) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AssetFamilys</h1>
|
||||
<p className="text-sm text-gray-500">Manage your asset families</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create AssetFamily
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetFamily } from "../hook/useAssetFamily";
|
||||
import { validateAssetFamily } from "../validation/asset-families.schema";
|
||||
import { assetFamiliesService } from "../services/asset-families.service";
|
||||
import type { AssetFamilyCreateRequest } from "../types/asset-families.types";
|
||||
|
||||
export default function NewAssetFamily() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useAssetFamily();
|
||||
|
||||
const [form, setForm] = useState<Partial<AssetFamilyCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
assetFamiliesService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateAssetFamily(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as AssetFamilyCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AssetFamily</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AssetFamilyList from '../pages/AssetFamilyList';
|
||||
import NewAssetFamily from '../pages/NewAssetFamily';
|
||||
|
||||
export const AssetFamilyRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<AssetFamilyList />} />
|
||||
<Route path="new" element={<NewAssetFamily />} />
|
||||
<Route path=":id/edit" element={<NewAssetFamily />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AssetFamily, AssetFamilyCreateRequest, AssetFamilyUpdateRequest } from '../types/asset-families.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_asset_families';
|
||||
|
||||
const getStored = (): AssetFamily[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: AssetFamily[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const assetFamiliesService = {
|
||||
getAll: async (): Promise<AssetFamily[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<AssetFamily | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: AssetFamilyCreateRequest): Promise<AssetFamily> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: AssetFamily = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: AssetFamilyUpdateRequest): Promise<AssetFamily> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface AssetFamily {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type AssetFamilyCreateRequest = Omit<AssetFamily, 'id' | 'createdAt'>;
|
||||
export type AssetFamilyUpdateRequest = Partial<AssetFamilyCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetFamilyCreateRequest } from '../types/asset-families.types';
|
||||
export const validateAssetFamily = (data: Partial<AssetFamilyCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'AssetFamily name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import { assetTypesService } from '../services/asset-types.service';
|
||||
export const assetTypesApi = assetTypesService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { assetTypesService } from '../services/asset-types.service';
|
||||
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useAssetType = () => {
|
||||
const [items, setItems] = useState<AssetType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await assetTypesService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: AssetTypeCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await assetTypesService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('AssetType created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: AssetTypeUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await assetTypesService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('AssetType updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await assetTypesService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('AssetType deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/asset-types.types';
|
||||
export * from './services/asset-types.service';
|
||||
export * from './hook/useAssetType';
|
||||
export * from './routes/asset-types.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import type { AssetType } from "../types/asset-types.types";
|
||||
|
||||
export default function AssetTypeList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useAssetType();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: AssetType) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: AssetType) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AssetTypes</h1>
|
||||
<p className="text-sm text-gray-500">Manage your asset types</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create AssetType
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAssetType } from "../hook/useAssetType";
|
||||
import { validateAssetType } from "../validation/asset-types.schema";
|
||||
import { assetTypesService } from "../services/asset-types.service";
|
||||
import type { AssetTypeCreateRequest } from "../types/asset-types.types";
|
||||
|
||||
export default function NewAssetType() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useAssetType();
|
||||
|
||||
const [form, setForm] = useState<Partial<AssetTypeCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
assetTypesService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateAssetType(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as AssetTypeCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AssetType</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AssetTypeList from '../pages/AssetTypeList';
|
||||
import NewAssetType from '../pages/NewAssetType';
|
||||
|
||||
export const AssetTypeRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<AssetTypeList />} />
|
||||
<Route path="new" element={<NewAssetType />} />
|
||||
<Route path=":id/edit" element={<NewAssetType />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AssetType, AssetTypeCreateRequest, AssetTypeUpdateRequest } from '../types/asset-types.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_asset_types';
|
||||
|
||||
const getStored = (): AssetType[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: AssetType[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const assetTypesService = {
|
||||
getAll: async (): Promise<AssetType[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<AssetType | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: AssetTypeCreateRequest): Promise<AssetType> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: AssetType = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: AssetTypeUpdateRequest): Promise<AssetType> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface AssetType {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type AssetTypeCreateRequest = Omit<AssetType, 'id' | 'createdAt'>;
|
||||
export type AssetTypeUpdateRequest = Partial<AssetTypeCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetTypeCreateRequest } from '../types/asset-types.types';
|
||||
export const validateAssetType = (data: Partial<AssetTypeCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'AssetType name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import { assetsService } from '../services/assets.service';
|
||||
export const assetsApi = assetsService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { assetsService } from '../services/assets.service';
|
||||
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useAsset = () => {
|
||||
const [items, setItems] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await assetsService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: AssetCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await assetsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('Asset created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: AssetUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await assetsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('Asset updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await assetsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('Asset deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/assets.types';
|
||||
export * from './services/assets.service';
|
||||
export * from './hook/useAsset';
|
||||
export * from './routes/assets.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useAsset } from "../hook/useAsset";
|
||||
import type { Asset } from "../types/assets.types";
|
||||
|
||||
export default function AssetList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useAsset();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: Asset) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: Asset) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assets</h1>
|
||||
<p className="text-sm text-gray-500">Manage your assets</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Asset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAsset } from "../hook/useAsset";
|
||||
import { validateAsset } from "../validation/assets.schema";
|
||||
import { assetsService } from "../services/assets.service";
|
||||
import type { AssetCreateRequest } from "../types/assets.types";
|
||||
|
||||
export default function NewAsset() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useAsset();
|
||||
|
||||
const [form, setForm] = useState<Partial<AssetCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
assetsService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateAsset(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as AssetCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Asset</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AssetList from '../pages/AssetList';
|
||||
import NewAsset from '../pages/NewAsset';
|
||||
|
||||
export const AssetRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<AssetList />} />
|
||||
<Route path="new" element={<NewAsset />} />
|
||||
<Route path=":id/edit" element={<NewAsset />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Asset, AssetCreateRequest, AssetUpdateRequest } from '../types/assets.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_assets';
|
||||
|
||||
const getStored = (): Asset[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: Asset[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const assetsService = {
|
||||
getAll: async (): Promise<Asset[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<Asset | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: AssetCreateRequest): Promise<Asset> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: Asset = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: AssetUpdateRequest): Promise<Asset> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Asset {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type AssetCreateRequest = Omit<Asset, 'id' | 'createdAt'>;
|
||||
export type AssetUpdateRequest = Partial<AssetCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetCreateRequest } from '../types/assets.types';
|
||||
export const validateAsset = (data: Partial<AssetCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'Asset name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import { attributeGroupsService } from '../services/attribute-groups.service';
|
||||
export const attributeGroupsApi = attributeGroupsService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { attributeGroupsService } from '../services/attribute-groups.service';
|
||||
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useAttributeGroup = () => {
|
||||
const [items, setItems] = useState<AttributeGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await attributeGroupsService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: AttributeGroupCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await attributeGroupsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('AttributeGroup created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: AttributeGroupUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await attributeGroupsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('AttributeGroup updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await attributeGroupsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('AttributeGroup deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/attribute-groups.types';
|
||||
export * from './services/attribute-groups.service';
|
||||
export * from './hook/useAttributeGroup';
|
||||
export * from './routes/attribute-groups.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useAttributeGroup } from "../hook/useAttributeGroup";
|
||||
import type { AttributeGroup } from "../types/attribute-groups.types";
|
||||
|
||||
export default function AttributeGroupList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useAttributeGroup();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: AttributeGroup) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: AttributeGroup) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AttributeGroups</h1>
|
||||
<p className="text-sm text-gray-500">Manage your attribute groups</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create AttributeGroup
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAttributeGroup } from "../hook/useAttributeGroup";
|
||||
import { validateAttributeGroup } from "../validation/attribute-groups.schema";
|
||||
import { attributeGroupsService } from "../services/attribute-groups.service";
|
||||
import type { AttributeGroupCreateRequest } from "../types/attribute-groups.types";
|
||||
|
||||
export default function NewAttributeGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useAttributeGroup();
|
||||
|
||||
const [form, setForm] = useState<Partial<AttributeGroupCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
attributeGroupsService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateAttributeGroup(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as AttributeGroupCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} AttributeGroup</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AttributeGroupList from '../pages/AttributeGroupList';
|
||||
import NewAttributeGroup from '../pages/NewAttributeGroup';
|
||||
|
||||
export const AttributeGroupRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<AttributeGroupList />} />
|
||||
<Route path="new" element={<NewAttributeGroup />} />
|
||||
<Route path=":id/edit" element={<NewAttributeGroup />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AttributeGroup, AttributeGroupCreateRequest, AttributeGroupUpdateRequest } from '../types/attribute-groups.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_attribute_groups';
|
||||
|
||||
const getStored = (): AttributeGroup[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: AttributeGroup[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const attributeGroupsService = {
|
||||
getAll: async (): Promise<AttributeGroup[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<AttributeGroup | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: AttributeGroupCreateRequest): Promise<AttributeGroup> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: AttributeGroup = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: AttributeGroupUpdateRequest): Promise<AttributeGroup> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface AttributeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type AttributeGroupCreateRequest = Omit<AttributeGroup, 'id' | 'createdAt'>;
|
||||
export type AttributeGroupUpdateRequest = Partial<AttributeGroupCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AttributeGroupCreateRequest } from '../types/attribute-groups.types';
|
||||
export const validateAttributeGroup = (data: Partial<AttributeGroupCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'AttributeGroup name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { attributeService } from '../services/attribute.service';
|
||||
|
||||
export const attributeApi = attributeService;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { attributeService } from '../services/attribute.service';
|
||||
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useAttribute = () => {
|
||||
const [attributes, setAttributes] = useState<Attribute[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAttributes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await attributeService.getAll();
|
||||
setAttributes(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch attributes');
|
||||
toast.error(err.message || 'Failed to fetch attributes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createAttribute = useCallback(async (req: AttributeCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await attributeService.create(req);
|
||||
setAttributes((prev) => [...prev, created]);
|
||||
toast.success('Attribute created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create attribute');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateAttribute = useCallback(async (id: string, req: AttributeUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await attributeService.update(id, req);
|
||||
setAttributes((prev) => prev.map((item) => (item.id === id ? updated : item)));
|
||||
toast.success('Attribute updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update attribute');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteAttribute = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await attributeService.delete(id);
|
||||
setAttributes((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success('Attribute deleted successfully!');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete attribute');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
attributes,
|
||||
loading,
|
||||
error,
|
||||
fetchAttributes,
|
||||
createAttribute,
|
||||
updateAttribute,
|
||||
deleteAttribute,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as AttributeList } from './pages/AttributeList';
|
||||
export { default as NewAttribute } from './pages/NewAttribute';
|
||||
export { default as AttributeRoutes } from './routes/attribute.routes';
|
||||
export * from './types/attribute.types';
|
||||
export * from './hook/useAttribute';
|
||||
export * from './validation/attribute.schema';
|
||||
export * from './services/attribute.service';
|
||||
export * from './api/attribute.api';
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect } from "react";
|
||||
import { Plus, Tag, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useAttribute } from "../hook/useAttribute";
|
||||
import type { Attribute } from "../types/attribute.types";
|
||||
import { formatDate } from "../../../utils/formatters";
|
||||
|
||||
export default function AttributeList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { attributes, fetchAttributes, deleteAttribute } = useAttribute();
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes();
|
||||
}, [fetchAttributes]);
|
||||
|
||||
const filteredAttributes = attributes.filter((attr) =>
|
||||
attr.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.group.toLowerCase().includes(query.toLowerCase()) ||
|
||||
attr.type.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete attribute "${name}"?`)) {
|
||||
await deleteAttribute(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "code", label: "Code", sortable: true },
|
||||
{ key: "name", label: "Name", sortable: true },
|
||||
{
|
||||
key: "type",
|
||||
label: "Type",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<span className="px-2.5 py-1 text-xs font-semibold rounded-md bg-purple-50 text-purple-700 capitalize border border-purple-100">
|
||||
{val}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "group", label: "Group", sortable: true },
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge
|
||||
status={val === "active" ? "active" : "disabled"}
|
||||
label={val === "active" ? "Active" : "Inactive"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "isRequired",
|
||||
label: "Required",
|
||||
render: (val: boolean) => (val ? "Yes" : "No"),
|
||||
},
|
||||
{
|
||||
key: "lastUpdated",
|
||||
label: "Last Updated",
|
||||
render: (val: string) => formatDate(val),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Tag className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Attributes</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage product custom fields, attributes, and options
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/attributes/new")}
|
||||
>
|
||||
Create Attribute
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search attributes by code, name, group, or type..."
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<Table<Attribute>
|
||||
columns={columns}
|
||||
data={filteredAttributes}
|
||||
onRowClick={(row) => navigate(`/attributes/${row.id}/edit`)}
|
||||
actions={(row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/attributes/${row.id}/edit`)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
|
||||
onClick={() => handleDelete(row.id, row.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Tag } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useAttribute } from "../hook/useAttribute";
|
||||
import { validateAttribute } from "../validation/attribute.schema";
|
||||
import type { AttributeCreateRequest } from "../types/attribute.types";
|
||||
|
||||
export default function NewAttribute() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { createAttribute, updateAttribute, fetchAttributes, attributes } = useAttribute();
|
||||
|
||||
const [form, setForm] = useState<Partial<AttributeCreateRequest>>({
|
||||
code: "",
|
||||
name: "",
|
||||
type: "text",
|
||||
group: "General",
|
||||
isRequired: false,
|
||||
isUnique: false,
|
||||
isLocalizable: false,
|
||||
options: [],
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [optionsText, setOptionsText] = useState("");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes();
|
||||
}, [fetchAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && attributes.length > 0) {
|
||||
const match = attributes.find((item) => item.id === id);
|
||||
if (match) {
|
||||
setForm({
|
||||
code: match.code,
|
||||
name: match.name,
|
||||
type: match.type,
|
||||
group: match.group,
|
||||
isRequired: match.isRequired,
|
||||
isUnique: match.isUnique,
|
||||
isLocalizable: match.isLocalizable,
|
||||
options: match.options || [],
|
||||
status: match.status,
|
||||
});
|
||||
if (match.options) {
|
||||
setOptionsText(match.options.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isEdit, id, attributes]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
if (type === "checkbox") {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setForm((prev) => ({ ...prev, [name]: checked }));
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setOptionsText(val);
|
||||
const parsedOptions = val
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
setForm((prev) => ({ ...prev, options: parsedOptions }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateAttribute(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateAttribute(id, form);
|
||||
} else {
|
||||
await createAttribute(form as AttributeCreateRequest);
|
||||
}
|
||||
navigate("/attributes");
|
||||
} catch {
|
||||
// hook handles toast
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.attributes">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/attributes")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Back to Attributes
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Tag className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Attribute" : "Create Attribute"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Attribute Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
value={form.code}
|
||||
onChange={handleChange}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. fabric_type"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
} disabled:bg-gray-50 disabled:text-gray-400`}
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Attribute Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Fabric Type"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Attribute Group <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="group"
|
||||
value={form.group}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. General, Technical, Dimensions"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.group ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.group && <p className="text-xs text-red-500 mt-1">{errors.group}</p>}
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Attribute Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
value={form.type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all bg-white"
|
||||
>
|
||||
<option value="text">Text (Single-line)</option>
|
||||
<option value="textarea">Textarea (Multi-line)</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean (Yes/No)</option>
|
||||
<option value="select">Select (Single choice)</option>
|
||||
<option value="multiselect">Multi-select (Multiple choices)</option>
|
||||
<option value="image">Image Upload</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Select Options */}
|
||||
{(form.type === "select" || form.type === "multiselect") && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Options (Comma separated) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={optionsText}
|
||||
onChange={handleOptionsChange}
|
||||
placeholder="e.g. Small, Medium, Large"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.options ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Separate values using commas.</p>
|
||||
{errors.options && <p className="text-xs text-red-500 mt-1">{errors.options}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flags */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isRequired"
|
||||
checked={form.isRequired}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Required</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isUnique"
|
||||
checked={form.isUnique}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Unique Value</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isLocalizable"
|
||||
checked={form.isLocalizable}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Localizable</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={form.status === "active"}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={form.status === "inactive"}
|
||||
onChange={handleChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("/attributes")} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>
|
||||
{isEdit ? "Save Changes" : "Create Attribute"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AttributeList from '../pages/AttributeList';
|
||||
import NewAttribute from '../pages/NewAttribute';
|
||||
|
||||
export const AttributeRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AttributeList />} />
|
||||
<Route path="new" element={<NewAttribute />} />
|
||||
<Route path=":id/edit" element={<NewAttribute />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributeRoutes;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Attribute, AttributeCreateRequest, AttributeUpdateRequest } from '../types/attribute.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_attributes';
|
||||
|
||||
const DEFAULT_ATTRIBUTES: Attribute[] = [
|
||||
{ id: '1', code: 'color', name: 'Color', type: 'select', group: 'General', isRequired: false, isUnique: false, isLocalizable: true, options: ['Red', 'Blue', 'Green', 'Black'], status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '2', code: 'size', name: 'Size', type: 'select', group: 'General', isRequired: false, isUnique: false, isLocalizable: false, options: ['S', 'M', 'L', 'XL'], status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '3', code: 'weight', name: 'Weight', type: 'number', group: 'Dimensions', isRequired: false, isUnique: false, isLocalizable: false, status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '4', code: 'description', name: 'Description', type: 'textarea', group: 'Marketing', isRequired: true, isUnique: false, isLocalizable: true, status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' }
|
||||
];
|
||||
|
||||
const getStoredAttributes = (): Attribute[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_ATTRIBUTES));
|
||||
return DEFAULT_ATTRIBUTES;
|
||||
}
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStoredAttributes = (attributes: Attribute[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(attributes));
|
||||
};
|
||||
|
||||
export const attributeService = {
|
||||
getAll: async (): Promise<Attribute[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(getStoredAttributes());
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Attribute | undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredAttributes();
|
||||
resolve(list.find(item => item.id === id));
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
|
||||
create: async (req: AttributeCreateRequest): Promise<Attribute> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredAttributes();
|
||||
const newAttr: Attribute = {
|
||||
...req,
|
||||
id: String(Date.now()),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
createdBy: 'Admin',
|
||||
};
|
||||
list.push(newAttr);
|
||||
setStoredAttributes(list);
|
||||
resolve(newAttr);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
update: async (id: string, req: AttributeUpdateRequest): Promise<Attribute> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredAttributes();
|
||||
const index = list.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
reject(new Error('Attribute not found'));
|
||||
return;
|
||||
}
|
||||
const updatedAttr: Attribute = {
|
||||
...list[index],
|
||||
...req,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
list[index] = updatedAttr;
|
||||
setStoredAttributes(list);
|
||||
resolve(updatedAttr);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredAttributes();
|
||||
const filtered = list.filter(item => item.id !== id);
|
||||
setStoredAttributes(filtered);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type AttributeType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'select'
|
||||
| 'multiselect'
|
||||
| 'image';
|
||||
|
||||
export type AttributeStatus = 'active' | 'inactive';
|
||||
|
||||
export interface Attribute {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
type: AttributeType;
|
||||
group: string;
|
||||
isRequired: boolean;
|
||||
isUnique: boolean;
|
||||
isLocalizable: boolean;
|
||||
options?: string[];
|
||||
status: AttributeStatus;
|
||||
lastUpdated: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type AttributeCreateRequest = Omit<Attribute, 'id' | 'lastUpdated' | 'createdBy'>;
|
||||
export type AttributeUpdateRequest = Partial<AttributeCreateRequest>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AttributeCreateRequest } from '../types/attribute.types';
|
||||
|
||||
export const validateAttribute = (data: Partial<AttributeCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!data.code?.trim()) {
|
||||
errors.code = 'Attribute code is required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
|
||||
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (!data.name?.trim()) {
|
||||
errors.name = 'Attribute name is required';
|
||||
}
|
||||
|
||||
if (!data.group?.trim()) {
|
||||
errors.group = 'Attribute group is required';
|
||||
}
|
||||
|
||||
if (!data.type) {
|
||||
errors.type = 'Attribute type is required';
|
||||
}
|
||||
|
||||
if ((data.type === 'select' || data.type === 'multiselect') && (!data.options || data.options.length === 0)) {
|
||||
errors.options = 'At least one option is required for selectable types';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { brandService } from '../services/brand.service';
|
||||
|
||||
export const brandApi = brandService;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { brandService } from '../services/brand.service';
|
||||
import type { Brand, BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useBrand = () => {
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchBrands = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await brandService.getAll();
|
||||
setBrands(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch brands');
|
||||
toast.error(err.message || 'Failed to fetch brands');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createBrand = useCallback(async (req: BrandCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await brandService.create(req);
|
||||
setBrands((prev) => [...prev, created]);
|
||||
toast.success('Brand created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create brand');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateBrand = useCallback(async (id: string, req: BrandUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await brandService.update(id, req);
|
||||
setBrands((prev) => prev.map((item) => (item.id === id ? updated : item)));
|
||||
toast.success('Brand updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update brand');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteBrand = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await brandService.delete(id);
|
||||
setBrands((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success('Brand deleted successfully!');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete brand');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
brands,
|
||||
loading,
|
||||
error,
|
||||
fetchBrands,
|
||||
createBrand,
|
||||
updateBrand,
|
||||
deleteBrand,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as BrandList } from './pages/BrandList';
|
||||
export { default as NewBrand } from './pages/NewBrand';
|
||||
export { default as BrandRoutes } from './routes/brand.routes';
|
||||
export * from './types/brand.types';
|
||||
export * from './hook/useBrand';
|
||||
export * from './services/brand.service';
|
||||
export * from './api/brand.api';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect } from "react";
|
||||
import { Plus, Award, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useBrand } from "../hook/useBrand";
|
||||
import type { Brand } from "../types/brand.types";
|
||||
import { formatDate } from "../../../utils/formatters";
|
||||
|
||||
export default function BrandList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { brands, fetchBrands, deleteBrand } = useBrand();
|
||||
|
||||
useEffect(() => {
|
||||
fetchBrands();
|
||||
}, [fetchBrands]);
|
||||
|
||||
const filteredBrands = brands.filter((brand) =>
|
||||
brand.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
brand.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(brand.description && brand.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete brand "${name}"?`)) {
|
||||
await deleteBrand(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "code", label: "Code", sortable: true },
|
||||
{ key: "name", label: "Brand Name", sortable: true },
|
||||
{ key: "description", label: "Description", sortable: true },
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge
|
||||
status={val === "active" ? "active" : "disabled"}
|
||||
label={val === "active" ? "Active" : "Inactive"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastUpdated",
|
||||
label: "Last Updated",
|
||||
render: (val: string) => formatDate(val),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="masters.brands">
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Award className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Brands</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage product brands and manufacturers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/brands/new")}
|
||||
>
|
||||
Create Brand
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search brands by code, name, or description..."
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<Table<Brand>
|
||||
columns={columns}
|
||||
data={filteredBrands}
|
||||
onRowClick={(row) => navigate(`/brands/${row.id}/edit`)}
|
||||
actions={(row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/brands/${row.id}/edit`)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
|
||||
onClick={() => handleDelete(row.id, row.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Award } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useBrand } from "../hook/useBrand";
|
||||
import type { BrandCreateRequest } from "../types/brand.types";
|
||||
|
||||
export default function NewBrand() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { createBrand, updateBrand, fetchBrands, brands } = useBrand();
|
||||
|
||||
const [form, setForm] = useState<Partial<BrandCreateRequest>>({
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBrands();
|
||||
}, [fetchBrands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && brands.length > 0) {
|
||||
const match = brands.find((item) => item.id === id);
|
||||
if (match) {
|
||||
setForm({
|
||||
code: match.code,
|
||||
name: match.name,
|
||||
description: match.description || "",
|
||||
status: match.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isEdit, id, brands]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, status: e.target.value as any }));
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const tempErrors: Record<string, string> = {};
|
||||
if (!form.code?.trim()) {
|
||||
tempErrors.code = "Brand code is required";
|
||||
} else if (!/^[a-z0-9_]+$/.test(form.code)) {
|
||||
tempErrors.code = "Code can only contain lowercase letters, numbers, and underscores";
|
||||
}
|
||||
|
||||
if (!form.name?.trim()) {
|
||||
tempErrors.name = "Brand name is required";
|
||||
}
|
||||
|
||||
setErrors(tempErrors);
|
||||
return Object.keys(tempErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateBrand(id, form);
|
||||
} else {
|
||||
await createBrand(form as BrandCreateRequest);
|
||||
}
|
||||
navigate("/brands");
|
||||
} catch {
|
||||
// hook handles toast
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="masters.brands">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/brands")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Back to Brands
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<Award className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Brand" : "Create Brand"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Brand Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
value={form.code}
|
||||
onChange={handleChange}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. nike"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
} disabled:bg-gray-50 disabled:text-gray-400`}
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Brand Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Nike"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Athletic apparel and accessories manufacturer"
|
||||
rows={3}
|
||||
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={form.status === "active"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={form.status === "inactive"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("/brands")} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>
|
||||
{isEdit ? "Save Changes" : "Create Brand"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import BrandList from '../pages/BrandList';
|
||||
import NewBrandForm from '../pages/NewBrandForm';
|
||||
|
||||
export const BrandRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<BrandList />} />
|
||||
<Route path="new" element={<NewBrandForm />} />
|
||||
<Route path=":id/edit" element={<NewBrandForm />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandRoutes;
|
||||
|
||||
// triggered language server update
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Brand, BrandCreateRequest, BrandUpdateRequest } from '../types/brand.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_brands';
|
||||
|
||||
const DEFAULT_BRANDS: Brand[] = [
|
||||
{ id: '1', code: 'nike', name: 'Nike', description: 'Athletic footwear and apparel', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '2', code: 'apple', name: 'Apple', description: 'Consumer electronics and software', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '3', code: 'sony', name: 'Sony', description: 'Gaming, electronics, and entertainment', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '4', code: 'samsung', name: 'Samsung', description: 'Multinational conglomerate and devices', status: 'inactive', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' }
|
||||
];
|
||||
|
||||
const getStoredBrands = (): Brand[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_BRANDS));
|
||||
return DEFAULT_BRANDS;
|
||||
}
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStoredBrands = (brands: Brand[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(brands));
|
||||
};
|
||||
|
||||
export const brandService = {
|
||||
getAll: async (): Promise<Brand[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(getStoredBrands());
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Brand | undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredBrands();
|
||||
resolve(list.find(item => item.id === id));
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
|
||||
create: async (req: BrandCreateRequest): Promise<Brand> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredBrands();
|
||||
const newBrand: Brand = {
|
||||
...req,
|
||||
id: String(Date.now()),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
createdBy: 'Admin',
|
||||
};
|
||||
list.push(newBrand);
|
||||
setStoredBrands(list);
|
||||
resolve(newBrand);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
update: async (id: string, req: BrandUpdateRequest): Promise<Brand> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredBrands();
|
||||
const index = list.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
reject(new Error('Brand not found'));
|
||||
return;
|
||||
}
|
||||
const updatedBrand: Brand = {
|
||||
...list[index],
|
||||
...req,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
list[index] = updatedBrand;
|
||||
setStoredBrands(list);
|
||||
resolve(updatedBrand);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredBrands();
|
||||
const filtered = list.filter(item => item.id !== id);
|
||||
setStoredBrands(filtered);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type BrandStatus = 'active' | 'inactive';
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: BrandStatus;
|
||||
lastUpdated: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type BrandCreateRequest = Omit<Brand, 'id' | 'lastUpdated' | 'createdBy'>;
|
||||
export type BrandUpdateRequest = Partial<BrandCreateRequest>;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { categoryService } from '../services/category.service';
|
||||
|
||||
export const categoryApi = categoryService;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
const CategoryTree = () => {
|
||||
return (
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
Category Tree View
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTree;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { categoryService } from '../services/category.service';
|
||||
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useCategory = () => {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCategories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await categoryService.getAll();
|
||||
setCategories(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch categories');
|
||||
toast.error(err.message || 'Failed to fetch categories');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createCategory = useCallback(async (req: CategoryCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await categoryService.create(req);
|
||||
setCategories((prev) => [...prev, created]);
|
||||
toast.success('Category created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create category');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCategory = useCallback(async (id: string, req: CategoryUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await categoryService.update(id, req);
|
||||
setCategories((prev) => prev.map((item) => (item.id === id ? updated : item)));
|
||||
toast.success('Category updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update category');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteCategory = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await categoryService.delete(id);
|
||||
setCategories((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success('Category deleted successfully!');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete category');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
error,
|
||||
fetchCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export { default as CategoryList } from './pages/CategoryList';
|
||||
export { default as NewCategory } from './pages/NewCategory';
|
||||
export { default as CategoryRoutes } from './routes/category.routes';
|
||||
export * from './types/category.types';
|
||||
export * from './hook/useCategory';
|
||||
export * from './validation/category.schema';
|
||||
export * from './services/category.service';
|
||||
export * from './api/category.api';
|
||||
export { default as CategoryTree } from './components/CategoryTree';
|
||||
export { default as useCategoryHook } from './hook/useCategory';
|
||||
export { default as categoryRoutes } from './routes/category.routes';
|
||||
export { default as categorySchema } from './validation/category.schema';
|
||||
export { default as categoryTypes } from './types/category.types';
|
||||
export { default as categoryAPI } from './api/category.api';
|
||||
export { default as categoryServiceFile } from './services/category.service';
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect } from "react";
|
||||
import { Plus, FolderTree, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useCategory } from "../hook/useCategory";
|
||||
import type { Category } from "../types/category.types";
|
||||
import { formatDate } from "../../../utils/formatters";
|
||||
|
||||
export default function CategoryList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { categories, fetchCategories, deleteCategory } = useCategory();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
const filteredCategories = categories.filter((cat) =>
|
||||
cat.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
cat.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(cat.parentName && cat.parentName.toLowerCase().includes(query.toLowerCase())) ||
|
||||
(cat.description && cat.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete category "${name}"?`)) {
|
||||
await deleteCategory(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "code", label: "Code", sortable: true },
|
||||
{ key: "name", label: "Category Name", sortable: true },
|
||||
{ key: "parentName", label: "Parent Category", sortable: true, render: (val: string) => val || "—" },
|
||||
{ key: "description", label: "Description", sortable: true },
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge
|
||||
status={val === "active" ? "active" : "disabled"}
|
||||
label={val === "active" ? "Active" : "Inactive"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "lastUpdated",
|
||||
label: "Last Updated",
|
||||
render: (val: string) => formatDate(val),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.categories">
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Categories</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage product category tree and categorization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/categories/new")}
|
||||
>
|
||||
Create Category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search categories by code, name, parent category, or description..."
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<Table<Category>
|
||||
columns={columns}
|
||||
data={filteredCategories}
|
||||
onRowClick={(row) => navigate(`/categories/${row.id}/edit`)}
|
||||
actions={(row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/categories/${row.id}/edit`)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
|
||||
onClick={() => handleDelete(row.id, row.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FolderTree } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useCategory } from "../hook/useCategory";
|
||||
import { validateCategory } from "../validation/category.schema";
|
||||
import type { CategoryCreateRequest } from "../types/category.types";
|
||||
|
||||
export default function NewCategory() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { createCategory, updateCategory, fetchCategories, categories } = useCategory();
|
||||
|
||||
const [form, setForm] = useState<Partial<CategoryCreateRequest>>({
|
||||
code: "",
|
||||
name: "",
|
||||
parentId: "",
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && categories.length > 0) {
|
||||
const match = categories.find((item) => item.id === id);
|
||||
if (match) {
|
||||
setForm({
|
||||
code: match.code,
|
||||
name: match.name,
|
||||
parentId: match.parentId || "",
|
||||
description: match.description || "",
|
||||
status: match.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isEdit, id, categories]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, status: e.target.value as any }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateCategory(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateCategory(id, form);
|
||||
} else {
|
||||
await createCategory(form as CategoryCreateRequest);
|
||||
}
|
||||
navigate("/categories");
|
||||
} catch {
|
||||
// hook handles toast
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out the current category from the parent choices to prevent self-reference
|
||||
const parentChoices = categories.filter((c) => c.status === "active" && (!isEdit || c.id !== id));
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.categories">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/categories")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Back to Categories
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Category" : "Create Category"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-2xl shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Category Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
value={form.code}
|
||||
onChange={handleChange}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. electronics"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
} disabled:bg-gray-50 disabled:text-gray-400`}
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Category Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Electronics"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Parent Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Parent Category</label>
|
||||
<select
|
||||
name="parentId"
|
||||
value={form.parentId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all bg-white"
|
||||
>
|
||||
<option value="">None (Top-level Category)</option>
|
||||
{parentChoices.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.parentName ? `${c.parentName} > ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Consumer electronics products and accessories"
|
||||
rows={3}
|
||||
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={form.status === "active"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={form.status === "inactive"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("/categories")} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>
|
||||
{isEdit ? "Save Changes" : "Create Category"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import CategoryList from '../pages/CategoryList';
|
||||
import NewCategory from '../pages/NewCategory';
|
||||
|
||||
export const CategoryRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<CategoryList />} />
|
||||
<Route path="new" element={<NewCategory />} />
|
||||
<Route path=":id/edit" element={<NewCategory />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryRoutes;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Category, CategoryCreateRequest, CategoryUpdateRequest } from '../types/category.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_categories';
|
||||
|
||||
const DEFAULT_CATEGORIES: Category[] = [
|
||||
{ id: '1', code: 'electronics', name: 'Electronics', description: 'Gears, gadgets, electronics', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '2', parentId: '1', parentName: 'Electronics', code: 'audio', name: 'Audio', description: 'Headphones, speakers, mics', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '3', parentId: '1', parentName: 'Electronics', code: 'computers', name: 'Computers', description: 'Laptops, PCs, parts', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '4', code: 'fashion', name: 'Fashion', description: 'Clothing, wear, styles', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '5', parentId: '4', parentName: 'Fashion', code: 'apparel', name: 'Apparel', description: 'Shirts, pants, hoodies', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' },
|
||||
{ id: '6', parentId: '4', parentName: 'Fashion', code: 'footwear', name: 'Footwear', description: 'Shoes, sneakers, sandals', status: 'active', lastUpdated: '2026-06-19T11:00:00Z', createdBy: 'Admin' }
|
||||
];
|
||||
|
||||
const getStoredCategories = (): Category[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_CATEGORIES));
|
||||
return DEFAULT_CATEGORIES;
|
||||
}
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStoredCategories = (categories: Category[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(categories));
|
||||
};
|
||||
|
||||
export const categoryService = {
|
||||
getAll: async (): Promise<Category[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(getStoredCategories());
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Category | undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredCategories();
|
||||
resolve(list.find(item => item.id === id));
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
|
||||
create: async (req: CategoryCreateRequest): Promise<Category> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredCategories();
|
||||
|
||||
let parentName: string | undefined;
|
||||
if (req.parentId) {
|
||||
const parent = list.find((c) => c.id === req.parentId);
|
||||
if (parent) {
|
||||
parentName = parent.name;
|
||||
}
|
||||
}
|
||||
|
||||
const newCategory: Category = {
|
||||
...req,
|
||||
parentName,
|
||||
id: String(Date.now()),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
createdBy: 'Admin',
|
||||
};
|
||||
list.push(newCategory);
|
||||
setStoredCategories(list);
|
||||
resolve(newCategory);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
update: async (id: string, req: CategoryUpdateRequest): Promise<Category> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredCategories();
|
||||
const index = list.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
reject(new Error('Category not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
let parentName: string | undefined;
|
||||
if (req.parentId) {
|
||||
const parent = list.find((c) => c.id === req.parentId);
|
||||
if (parent) {
|
||||
parentName = parent.name;
|
||||
}
|
||||
} else if (req.parentId === "") {
|
||||
// Cleared parent
|
||||
parentName = undefined;
|
||||
} else {
|
||||
parentName = list[index].parentName;
|
||||
}
|
||||
|
||||
const updatedCategory: Category = {
|
||||
...list[index],
|
||||
...req,
|
||||
parentName,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
list[index] = updatedCategory;
|
||||
setStoredCategories(list);
|
||||
resolve(updatedCategory);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredCategories();
|
||||
// Delete category and also update child categories to have no parent
|
||||
const updated = list
|
||||
.filter(item => item.id !== id)
|
||||
.map((item) => {
|
||||
if (item.parentId === id) {
|
||||
return { ...item, parentId: undefined, parentName: undefined };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setStoredCategories(updated);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type CategoryStatus = 'active' | 'inactive';
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
parentName?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: CategoryStatus;
|
||||
lastUpdated: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type CategoryCreateRequest = Omit<Category, 'id' | 'lastUpdated' | 'createdBy' | 'parentName'>;
|
||||
export type CategoryUpdateRequest = Partial<CategoryCreateRequest>;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CategoryCreateRequest } from '../types/category.types';
|
||||
|
||||
export const validateCategory = (data: Partial<CategoryCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!data.code?.trim()) {
|
||||
errors.code = 'Category code is required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
|
||||
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (!data.name?.trim()) {
|
||||
errors.name = 'Category name is required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { channelsService } from '../services/channels.service';
|
||||
export const channelsApi = channelsService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { channelsService } from '../services/channels.service';
|
||||
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useChannel = () => {
|
||||
const [items, setItems] = useState<Channel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await channelsService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: ChannelCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await channelsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('Channel created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: ChannelUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await channelsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('Channel updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await channelsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('Channel deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/channels.types';
|
||||
export * from './services/channels.service';
|
||||
export * from './hook/useChannel';
|
||||
export * from './routes/channels.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useChannel } from "../hook/useChannel";
|
||||
import type { Channel } from "../types/channels.types";
|
||||
|
||||
export default function ChannelList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useChannel();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: Channel) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: Channel) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Channels</h1>
|
||||
<p className="text-sm text-gray-500">Manage your channels</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Channel
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useChannel } from "../hook/useChannel";
|
||||
import { validateChannel } from "../validation/channels.schema";
|
||||
import { channelsService } from "../services/channels.service";
|
||||
import type { ChannelCreateRequest } from "../types/channels.types";
|
||||
|
||||
export default function NewChannel() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useChannel();
|
||||
|
||||
const [form, setForm] = useState<Partial<ChannelCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
channelsService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateChannel(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as ChannelCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Channel</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import ChannelList from '../pages/ChannelList';
|
||||
import NewChannel from '../pages/NewChannel';
|
||||
|
||||
export const ChannelRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<ChannelList />} />
|
||||
<Route path="new" element={<NewChannel />} />
|
||||
<Route path=":id/edit" element={<NewChannel />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Channel, ChannelCreateRequest, ChannelUpdateRequest } from '../types/channels.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_channels';
|
||||
|
||||
const getStored = (): Channel[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: Channel[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const channelsService = {
|
||||
getAll: async (): Promise<Channel[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<Channel | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: ChannelCreateRequest): Promise<Channel> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: Channel = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: ChannelUpdateRequest): Promise<Channel> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type ChannelCreateRequest = Omit<Channel, 'id' | 'createdAt'>;
|
||||
export type ChannelUpdateRequest = Partial<ChannelCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ChannelCreateRequest } from '../types/channels.types';
|
||||
export const validateChannel = (data: Partial<ChannelCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'Channel name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { familyService } from '../services/family.service';
|
||||
|
||||
export const familyApi = familyService;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { familyService } from '../services/family.service';
|
||||
import type { Family, FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useFamily = () => {
|
||||
const [families, setFamilies] = useState<Family[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchFamilies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await familyService.getAll();
|
||||
setFamilies(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch families');
|
||||
toast.error(err.message || 'Failed to fetch families');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createFamily = useCallback(async (req: FamilyCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await familyService.create(req);
|
||||
setFamilies((prev) => [...prev, created]);
|
||||
toast.success('Family created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create family');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateFamily = useCallback(async (id: string, req: FamilyUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await familyService.update(id, req);
|
||||
setFamilies((prev) => prev.map((item) => (item.id === id ? updated : item)));
|
||||
toast.success('Family updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update family');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteFamily = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await familyService.delete(id);
|
||||
setFamilies((prev) => prev.filter((item) => item.id !== id));
|
||||
toast.success('Family deleted successfully!');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete family');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
families,
|
||||
loading,
|
||||
error,
|
||||
fetchFamilies,
|
||||
createFamily,
|
||||
updateFamily,
|
||||
deleteFamily,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as FamilyList } from './pages/FamilyList';
|
||||
export { default as NewFamily } from './pages/NewFamily';
|
||||
export { default as FamilyRoutes } from './routes/family.routes';
|
||||
export * from './types/family.types';
|
||||
export * from './hook/useFamily';
|
||||
export * from './validation/family.schema';
|
||||
export * from './services/family.service';
|
||||
export * from './api/family.api';
|
||||
|
||||
@@ -1,25 +1,217 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Plus, FolderTree, Edit2, Trash2, LayoutGrid, BookCheck, Box, TrendingUp } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { SearchBar, useSearch } from "../../../components/customs/SearchBar";
|
||||
import { StatusBadge } from "../../../components/customs/StatusBadge";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { InfoCard, InfoCardGrid } from "../../../components/customs/InfoCards";
|
||||
import { useFamily } from "../hook/useFamily";
|
||||
import type { Family } from "../types/family.types";
|
||||
import { formatDate } from "../../../utils/formatters";
|
||||
|
||||
const FamilyList = () => {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Product Families
|
||||
</h1>
|
||||
export default function FamilyList() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useSearch();
|
||||
const { families, fetchFamilies, deleteFamily } = useFamily();
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage product family definitions and variant axes.
|
||||
</p>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
fetchFamilies();
|
||||
}, [fetchFamilies]);
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6">
|
||||
Family table goes here
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
const stats = useMemo(() => {
|
||||
const published = families.filter((f) => f.status === 'active').length;
|
||||
const totalProducts = families.reduce((sum, f) => sum + (f.productCount ?? 0), 0);
|
||||
const avgCompleteness =
|
||||
families.length > 0
|
||||
? Math.round(families.reduce((sum, f) => sum + (f.completeness ?? 100), 0) / families.length)
|
||||
: 0;
|
||||
return { total: families.length, published, totalProducts, avgCompleteness };
|
||||
}, [families]);
|
||||
|
||||
const filteredFamilies = families.filter((fam) =>
|
||||
fam.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
fam.code.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(fam.description && fam.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
export default FamilyList;
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (confirm(`Are you sure you want to delete product family "${name}"?`)) {
|
||||
await deleteFamily(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
label: "Family Name",
|
||||
sortable: true,
|
||||
render: (_val: string, row: Family) => (
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">{row.name}</div>
|
||||
{row.description && (
|
||||
<div className="text-xs text-gray-400 mt-0.5 max-w-[180px] line-clamp-2">{row.description}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "code", label: "Code", sortable: true,
|
||||
render: (val: string) => <span className="font-mono text-sm text-gray-600">{val}</span>,
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "Category",
|
||||
render: (val: string) => <span className="text-sm text-gray-600">{val || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: "attributes",
|
||||
label: "Attributes",
|
||||
render: (val: string[], row: Family) => (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-gray-900">{val.length}</div>
|
||||
<div className="text-xs text-gray-400">{row.attributeGroups ?? 0} groups</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "variantAxes",
|
||||
label: "Variants",
|
||||
render: (val: string[]) => (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-gray-900">{val.length}</div>
|
||||
<div className="text-xs text-gray-400">{val.length} {val.length === 1 ? 'axis' : 'axes'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "productCount",
|
||||
label: "Products",
|
||||
sortable: true,
|
||||
render: (val: number) => (
|
||||
<span className="font-semibold text-gray-900">{(val ?? 0).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "completeness",
|
||||
label: "Completeness",
|
||||
sortable: true,
|
||||
render: (val: number) => {
|
||||
const pct = val ?? 0;
|
||||
const color = pct >= 90 ? 'bg-green-500' : pct >= 70 ? 'bg-amber-400' : 'bg-red-400';
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<span className={`text-sm font-semibold ${pct >= 90 ? 'text-green-600' : pct >= 70 ? 'text-amber-600' : 'text-red-500'}`}>
|
||||
{pct}%
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
sortable: true,
|
||||
render: (val: string) => (
|
||||
<StatusBadge
|
||||
status={val === "active" ? "active" : "disabled"}
|
||||
label={val === "active" ? "Active" : "Inactive"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProtectedRoute node="products.families">
|
||||
<PageWrapper>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Product Families</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Manage product family templates with variant configurations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => navigate("/families/new")}
|
||||
>
|
||||
Create Family
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<InfoCardGrid cols={4} className="mb-6">
|
||||
<InfoCard
|
||||
label="Total Families"
|
||||
value={stats.total}
|
||||
icon={<LayoutGrid className="w-5 h-5" />}
|
||||
variant="purple"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Published"
|
||||
value={stats.published}
|
||||
icon={<BookCheck className="w-5 h-5" />}
|
||||
variant="green"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Total Products"
|
||||
value={stats.totalProducts.toLocaleString()}
|
||||
icon={<Box className="w-5 h-5" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<InfoCard
|
||||
label="Avg Completeness"
|
||||
value={`${stats.avgCompleteness}%`}
|
||||
icon={<TrendingUp className="w-5 h-5" />}
|
||||
variant="amber"
|
||||
/>
|
||||
</InfoCardGrid>
|
||||
|
||||
{/* Search */}
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search product families by code, name, or description..."
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<Table<Family>
|
||||
columns={columns}
|
||||
data={filteredFamilies}
|
||||
onRowClick={(row) => navigate(`/families/${row.id}/edit`)}
|
||||
actions={(row) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Edit2 className="w-4 h-4" />}
|
||||
onClick={() => navigate(`/families/${row.id}/edit`)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-4 h-4 text-red-500 hover:bg-red-50" />}
|
||||
onClick={() => handleDelete(row.id, row.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,291 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, FolderTree } from "lucide-react";
|
||||
import { ProtectedRoute } from "../../../components/layouts/ProtectedRoute";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useFamily } from "../hook/useFamily";
|
||||
import { useAttribute } from "../../attributes/hook/useAttribute";
|
||||
import { validateFamily } from "../validation/family.schema";
|
||||
import type { FamilyCreateRequest } from "../types/family.types";
|
||||
|
||||
export default function NewFamily() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const { createFamily, updateFamily, fetchFamilies, families } = useFamily();
|
||||
const { fetchAttributes, attributes } = useAttribute();
|
||||
|
||||
const [form, setForm] = useState<Partial<FamilyCreateRequest>>({
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
attributes: [],
|
||||
variantAxes: [],
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFamilies();
|
||||
fetchAttributes();
|
||||
}, [fetchFamilies, fetchAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && families.length > 0) {
|
||||
const match = families.find((item) => item.id === id);
|
||||
if (match) {
|
||||
setForm({
|
||||
code: match.code,
|
||||
name: match.name,
|
||||
description: match.description || "",
|
||||
attributes: match.attributes || [],
|
||||
variantAxes: match.variantAxes || [],
|
||||
status: match.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isEdit, id, families]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, status: e.target.value as any }));
|
||||
};
|
||||
|
||||
const handleAttributeToggle = (code: string) => {
|
||||
setForm((prev) => {
|
||||
const current = prev.attributes || [];
|
||||
const updated = current.includes(code)
|
||||
? current.filter((c) => c !== code)
|
||||
: [...current, code];
|
||||
|
||||
// If we uncheck an attribute, make sure it is also removed from variant axes
|
||||
const updatedAxes = (prev.variantAxes || []).filter((axis) => updated.includes(axis));
|
||||
|
||||
return {
|
||||
...prev,
|
||||
attributes: updated,
|
||||
variantAxes: updatedAxes,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleAxisToggle = (code: string) => {
|
||||
setForm((prev) => {
|
||||
const current = prev.variantAxes || [];
|
||||
const updated = current.includes(code)
|
||||
? current.filter((c) => c !== code)
|
||||
: [...current, code];
|
||||
return {
|
||||
...prev,
|
||||
variantAxes: updated,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateFamily(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateFamily(id, form);
|
||||
} else {
|
||||
await createFamily(form as FamilyCreateRequest);
|
||||
}
|
||||
navigate("/families");
|
||||
} catch {
|
||||
// hook handles toast
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeAttributes = attributes.filter((a) => a.status === "active");
|
||||
|
||||
const NewFamily = () => {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Create Product Family
|
||||
</h1>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new family and define variant axes.
|
||||
</p>
|
||||
<ProtectedRoute node="products.families">
|
||||
<PageWrapper>
|
||||
{/* Back and Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate("/families")}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors mb-4 group cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Back to Product Families
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-purple-50 text-purple-600 rounded-lg">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEdit ? "Edit Product Family" : "Create Product Family"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6">
|
||||
Family form goes here
|
||||
{/* Form Container */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 max-w-3xl shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Family Code <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
value={form.code}
|
||||
onChange={handleChange}
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. apparel"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.code ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
} disabled:bg-gray-50 disabled:text-gray-400`}
|
||||
/>
|
||||
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
|
||||
Family Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Apparel"
|
||||
className={`w-full px-3.5 py-2 border rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all ${
|
||||
errors.name ? "border-red-400 focus:ring-red-100" : "border-gray-300"
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g. Products requiring variant sizes and colors"
|
||||
rows={3}
|
||||
className="w-full px-3.5 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-200 focus:border-purple-600 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attributes Checklist */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Linked Attributes</label>
|
||||
{activeAttributes.length === 0 ? (
|
||||
<p className="text-sm text-gray-450 italic">No attributes defined. Create some attributes first.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 border border-gray-150 rounded-lg p-4 bg-gray-50/50 max-h-48 overflow-y-auto">
|
||||
{activeAttributes.map((attr) => {
|
||||
const isChecked = (form.attributes || []).includes(attr.code);
|
||||
return (
|
||||
<label key={attr.id} className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleAttributeToggle(attr.code)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="capitalize">{attr.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variant Axes Checklist */}
|
||||
{(form.attributes || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Variant Axes (Select from linked attributes)
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 border border-gray-150 rounded-lg p-4 bg-gray-50/50 max-h-48 overflow-y-auto">
|
||||
{(form.attributes || []).map((code) => {
|
||||
const attr = activeAttributes.find((a) => a.code === code);
|
||||
if (!attr) return null;
|
||||
const isChecked = (form.variantAxes || []).includes(code);
|
||||
return (
|
||||
<label key={code} className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleAxisToggle(code)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="capitalize">{attr.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Selected attributes will serve as the coordinate axes for item variant variations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-1.5">Status</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="active"
|
||||
checked={form.status === "active"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="inactive"
|
||||
checked={form.status === "inactive"}
|
||||
onChange={handleStatusChange}
|
||||
className="w-4 h-4 border-gray-300 text-purple-600 focus:ring-purple-500 accent-purple-600 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("/families")} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>
|
||||
{isEdit ? "Save Changes" : "Create Family"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</PageWrapper>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewFamily;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Family, FamilyCreateRequest, FamilyUpdateRequest } from '../types/family.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_families';
|
||||
|
||||
const DEFAULT_FAMILIES: Family[] = [
|
||||
{
|
||||
id: '1',
|
||||
code: 'laptop_family',
|
||||
name: 'Laptop Family',
|
||||
description: 'All laptop products with storage, memory, and color variants',
|
||||
category: 'Electronics > Computers > Laptops',
|
||||
attributes: Array.from({ length: 42 }, (_, i) => `attr_${i + 1}`),
|
||||
attributeGroups: 5,
|
||||
variantAxes: ['storage', 'memory', 'color'],
|
||||
productCount: 2847,
|
||||
completeness: 100,
|
||||
status: 'active',
|
||||
lastUpdated: '2026-06-19T11:00:00Z',
|
||||
createdBy: 'Admin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
code: 'smartphone_family',
|
||||
name: 'Smartphone Family',
|
||||
description: 'Mobile phones with storage and color variants',
|
||||
category: 'Electronics > Smartphones',
|
||||
attributes: Array.from({ length: 38 }, (_, i) => `attr_${i + 1}`),
|
||||
attributeGroups: 6,
|
||||
variantAxes: ['storage', 'color'],
|
||||
productCount: 3456,
|
||||
completeness: 100,
|
||||
status: 'active',
|
||||
lastUpdated: '2026-06-19T10:00:00Z',
|
||||
createdBy: 'Admin',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
code: 'tablet_family',
|
||||
name: 'Tablet Family',
|
||||
description: 'Tablets with screen size and storage variants',
|
||||
category: 'Electronics > Tablets',
|
||||
attributes: Array.from({ length: 28 }, (_, i) => `attr_${i + 1}`),
|
||||
attributeGroups: 4,
|
||||
variantAxes: ['screen_size', 'storage'],
|
||||
productCount: 892,
|
||||
completeness: 85,
|
||||
status: 'active',
|
||||
lastUpdated: '2026-06-18T09:30:00Z',
|
||||
createdBy: 'Admin',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
code: 'accessories_family',
|
||||
name: 'Accessories Family',
|
||||
description: 'Phone cases, chargers, and peripherals',
|
||||
category: 'Electronics > Accessories',
|
||||
attributes: Array.from({ length: 18 }, (_, i) => `attr_${i + 1}`),
|
||||
attributeGroups: 3,
|
||||
variantAxes: ['color'],
|
||||
productCount: 784,
|
||||
completeness: 72,
|
||||
status: 'active',
|
||||
lastUpdated: '2026-06-17T14:00:00Z',
|
||||
createdBy: 'Admin',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
code: 'wearable_family',
|
||||
name: 'Wearable Family',
|
||||
description: 'Smartwatches, fitness bands, and wearable tech',
|
||||
category: 'Electronics > Wearables',
|
||||
attributes: Array.from({ length: 22 }, (_, i) => `attr_${i + 1}`),
|
||||
attributeGroups: 3,
|
||||
variantAxes: ['size', 'color'],
|
||||
productCount: 0,
|
||||
completeness: 60,
|
||||
status: 'inactive',
|
||||
lastUpdated: '2026-06-15T08:00:00Z',
|
||||
createdBy: 'Admin',
|
||||
},
|
||||
];
|
||||
|
||||
const getStoredFamilies = (): Family[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Family[];
|
||||
// Re-seed if old data is missing the new fields
|
||||
if (parsed.length > 0 && parsed[0].productCount === undefined) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_FAMILIES));
|
||||
return DEFAULT_FAMILIES;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_FAMILIES));
|
||||
return DEFAULT_FAMILIES;
|
||||
};
|
||||
|
||||
const setStoredFamilies = (families: Family[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(families));
|
||||
};
|
||||
|
||||
export const familyService = {
|
||||
getAll: async (): Promise<Family[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(getStoredFamilies());
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Family | undefined> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredFamilies();
|
||||
resolve(list.find(item => item.id === id));
|
||||
}, 200);
|
||||
});
|
||||
},
|
||||
|
||||
create: async (req: FamilyCreateRequest): Promise<Family> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredFamilies();
|
||||
const newFamily: Family = {
|
||||
...req,
|
||||
id: String(Date.now()),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
createdBy: 'Admin',
|
||||
};
|
||||
list.push(newFamily);
|
||||
setStoredFamilies(list);
|
||||
resolve(newFamily);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
update: async (id: string, req: FamilyUpdateRequest): Promise<Family> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredFamilies();
|
||||
const index = list.findIndex(item => item.id === id);
|
||||
if (index === -1) {
|
||||
reject(new Error('Family not found'));
|
||||
return;
|
||||
}
|
||||
const updatedFamily: Family = {
|
||||
...list[index],
|
||||
...req,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
list[index] = updatedFamily;
|
||||
setStoredFamilies(list);
|
||||
resolve(updatedFamily);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStoredFamilies();
|
||||
const filtered = list.filter(item => item.id !== id);
|
||||
setStoredFamilies(filtered);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type FamilyStatus = 'active' | 'inactive';
|
||||
|
||||
export interface Family {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
attributes: string[]; // List of attribute codes
|
||||
attributeGroups?: number;
|
||||
variantAxes: string[]; // List of attribute codes used as variant axes
|
||||
productCount?: number;
|
||||
completeness?: number; // percentage 0–100
|
||||
status: FamilyStatus;
|
||||
lastUpdated: string;
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
export type FamilyCreateRequest = Omit<Family, 'id' | 'lastUpdated' | 'createdBy'>;
|
||||
export type FamilyUpdateRequest = Partial<FamilyCreateRequest>;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FamilyCreateRequest } from '../types/family.types';
|
||||
|
||||
export const validateFamily = (data: Partial<FamilyCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!data.code?.trim()) {
|
||||
errors.code = 'Family code is required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(data.code)) {
|
||||
errors.code = 'Code can only contain lowercase letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
if (!data.name?.trim()) {
|
||||
errors.name = 'Family name is required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { importsService } from '../services/imports.service';
|
||||
export const importsApi = importsService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { importsService } from '../services/imports.service';
|
||||
import type { Import, ImportCreateRequest, ImportUpdateRequest } from '../types/imports.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useImport = () => {
|
||||
const [items, setItems] = useState<Import[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await importsService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: ImportCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await importsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('Import created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: ImportUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await importsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('Import updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await importsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('Import deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './types/imports.types';
|
||||
export * from './services/imports.service';
|
||||
export * from './hook/useImport';
|
||||
export * from './routes/imports.routes';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { Table } from "../../../components/customs/Table";
|
||||
import { useImport } from "../hook/useImport";
|
||||
import type { Import } from "../types/imports.types";
|
||||
|
||||
export default function ImportList() {
|
||||
const navigate = useNavigate();
|
||||
const { items, fetchItems, deleteItem } = useImport();
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleSelect = (ids: Set<string>) => setSelected(ids);
|
||||
|
||||
const handleDelete = async (item: Import) => {
|
||||
if (confirm('Delete this item?')) {
|
||||
await deleteItem(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", sortable: true },
|
||||
{ key: "status", header: "Status", sortable: true },
|
||||
{ key: "createdAt", header: "Created At", sortable: true, render: (val: string) => new Date(val).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const actions = (item: Import) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" icon={<Edit2 className="w-4 h-4" />} onClick={() => navigate(item.id + "/edit")} />
|
||||
<Button variant="ghost" size="sm" icon={<Trash2 className="w-4 h-4 text-red-500" />} onClick={() => handleDelete(item)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Imports</h1>
|
||||
<p className="text-sm text-gray-500">Manage your imports</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Plus className="w-4 h-4" />} onClick={() => navigate("new")}>
|
||||
Create Import
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<Table data={items} columns={columns} selectable onSelectionChange={handleSelect} actions={actions} />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { PageWrapper } from "../../../components/layouts/PageWrapper";
|
||||
import { Button } from "../../../components/customs/Button";
|
||||
import { useImport } from "../hook/useImport";
|
||||
import { validateImport } from "../validation/imports.schema";
|
||||
import { importsService } from "../services/imports.service";
|
||||
import type { ImportCreateRequest } from "../types/imports.types";
|
||||
|
||||
export default function NewImport() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { createItem, updateItem } = useImport();
|
||||
|
||||
const [form, setForm] = useState<Partial<ImportCreateRequest>>({ name: "", status: "active" });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
importsService.getById(id).then(item => {
|
||||
if (item) setForm({ name: item.name, status: item.status });
|
||||
});
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const validationErrors = validateImport(form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateItem(id, form as any);
|
||||
} else {
|
||||
await createItem(form as ImportCreateRequest);
|
||||
}
|
||||
navigate("..");
|
||||
} catch {
|
||||
// toast handled
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">{isEdit ? 'Edit' : 'Create'} Import</h1>
|
||||
<form onSubmit={handleSubmit} className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2" />
|
||||
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="outline" type="button" onClick={() => navigate("..")}>Cancel</Button>
|
||||
<Button variant="primary" type="submit" loading={saving}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import ImportList from '../pages/ImportList';
|
||||
import NewImport from '../pages/NewImport';
|
||||
|
||||
export const ImportRoutes = () => (
|
||||
<Routes>
|
||||
<Route index element={<ImportList />} />
|
||||
<Route path="new" element={<NewImport />} />
|
||||
<Route path=":id/edit" element={<NewImport />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Import, ImportCreateRequest, ImportUpdateRequest } from '../types/imports.types';
|
||||
|
||||
const STORAGE_KEY = 'pim_imports';
|
||||
|
||||
const getStored = (): Import[] => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored);
|
||||
};
|
||||
|
||||
const setStored = (items: Import[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
export const importsService = {
|
||||
getAll: async (): Promise<Import[]> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored()), 300));
|
||||
},
|
||||
getById: async (id: string): Promise<Import | undefined> => {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(getStored().find(p => p.id === id)), 200));
|
||||
},
|
||||
create: async (req: ImportCreateRequest): Promise<Import> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const newItem: Import = { ...req, id: String(Date.now()), createdAt: new Date().toISOString() };
|
||||
list.push(newItem);
|
||||
setStored(list);
|
||||
resolve(newItem);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
update: async (id: string, req: ImportUpdateRequest): Promise<Import> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored();
|
||||
const index = list.findIndex(p => p.id === id);
|
||||
if (index === -1) { reject(new Error('Not found')); return; }
|
||||
const updated = { ...list[index], ...req };
|
||||
list[index] = updated;
|
||||
setStored(list);
|
||||
resolve(updated);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
delete: async (id: string): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const list = getStored().filter(p => p.id !== id);
|
||||
setStored(list);
|
||||
resolve(true);
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Import {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
}
|
||||
export type ImportCreateRequest = Omit<Import, 'id' | 'createdAt'>;
|
||||
export type ImportUpdateRequest = Partial<ImportCreateRequest>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ImportCreateRequest } from '../types/imports.types';
|
||||
export const validateImport = (data: Partial<ImportCreateRequest>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
if (!data.name?.trim()) errors.name = 'Import name is required';
|
||||
return errors;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
export const integrationsApi = integrationsService;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { integrationsService } from '../services/integrations.service';
|
||||
import type { Integration, IntegrationCreateRequest, IntegrationUpdateRequest } from '../types/integrations.types';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useIntegration = () => {
|
||||
const [items, setItems] = useState<Integration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await integrationsService.getAll();
|
||||
setItems(data);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to fetch items');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createItem = useCallback(async (req: IntegrationCreateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await integrationsService.create(req);
|
||||
setItems((prev) => [...prev, created]);
|
||||
toast.success('Integration created successfully!');
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to create item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(async (id: string, req: IntegrationUpdateRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const updated = await integrationsService.update(id, req);
|
||||
setItems((prev) => prev.map((p) => (p.id === id ? updated : p)));
|
||||
toast.success('Integration updated successfully!');
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteItem = useCallback(async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await integrationsService.delete(id);
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success('Integration deleted successfully!');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete item');
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { items, loading, fetchItems, createItem, updateItem, deleteItem };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user