implemented the forntend screens

This commit is contained in:
liyaqath
2026-06-19 19:38:24 +05:30
parent 3d62258860
commit dde3413bfc
183 changed files with 9221 additions and 715 deletions
+345 -327
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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",
+109
View File
@@ -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));
+468
View File
@@ -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`);
}
+339
View File
@@ -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!');
+57
View File
@@ -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;
+134
View File
@@ -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>
);
}
+244
View File
@@ -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>
);
}
+1 -46
View File
@@ -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 };
};
+4
View File
@@ -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 };
};
+4
View File
@@ -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;
};
+2
View File
@@ -0,0 +1,2 @@
import { assetsService } from '../services/assets.service';
export const assetsApi = assetsService;
+67
View File
@@ -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 };
};
+4
View File
@@ -0,0 +1,4 @@
export * from './types/assets.types';
export * from './services/assets.service';
export * from './hook/useAsset';
export * from './routes/assets.routes';
+56
View File
@@ -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>
);
}
+69
View File
@@ -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 };
};
+4
View File
@@ -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,
};
};
+8
View File
@@ -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;
};
+3
View File
@@ -0,0 +1,3 @@
import { brandService } from '../services/brand.service';
export const brandApi = brandService;
+79
View File
@@ -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,
};
};
+7
View File
@@ -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';
+118
View File
@@ -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>
);
}
+207
View File
@@ -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);
});
},
};
+14
View File
@@ -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,
};
};
+15
View File
@@ -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;
+67
View File
@@ -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 };
};
+4
View File
@@ -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;
};
+3
View File
@@ -0,0 +1,3 @@
import { familyService } from '../services/family.service';
export const familyApi = familyService;
+79
View File
@@ -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,
};
};
+8
View File
@@ -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';
+211 -19
View File
@@ -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>
);
}
+284 -18
View File
@@ -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);
});
},
};
+20
View File
@@ -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 0100
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;
};
+2
View File
@@ -0,0 +1,2 @@
import { importsService } from '../services/imports.service';
export const importsApi = importsService;
+67
View File
@@ -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 };
};
+4
View File
@@ -0,0 +1,4 @@
export * from './types/imports.types';
export * from './services/imports.service';
export * from './hook/useImport';
export * from './routes/imports.routes';
+56
View File
@@ -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>
);
}
+69
View File
@@ -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