253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
import { mdiDownload, mdiFileUploadOutline, mdiHomeCity } from '@mdi/js';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import React, { ReactElement, useMemo, useState } from 'react';
|
|
|
|
import BaseButton from '../components/BaseButton';
|
|
import CardBox from '../components/CardBox';
|
|
import DragDropFilePicker from '../components/DragDropFilePicker';
|
|
import SectionMain from '../components/SectionMain';
|
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../config';
|
|
import { portfolioWorkbookSheets } from '../helpers/portfolioWorkbookSheets';
|
|
import { hasPermission } from '../helpers/userPermissions';
|
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
|
import { useAppSelector } from '../stores/hooks';
|
|
|
|
type ImportSummary = Record<string, { created: number; reused: number }>;
|
|
|
|
type ImportResponse = {
|
|
workbook: {
|
|
fileName: string;
|
|
sheets: Array<{
|
|
key: string;
|
|
name: string;
|
|
rows: number;
|
|
}>;
|
|
};
|
|
summary: ImportSummary;
|
|
};
|
|
|
|
const createPermissions = [
|
|
'CREATE_TENANTS',
|
|
'CREATE_ORGANIZATIONS',
|
|
'CREATE_PROPERTIES',
|
|
'CREATE_UNIT_TYPES',
|
|
'CREATE_UNITS',
|
|
];
|
|
|
|
const summaryLabels: Record<string, string> = {
|
|
tenants: 'Tenants',
|
|
organizations: 'Organizations',
|
|
properties: 'Properties',
|
|
unit_types: 'Unit Types',
|
|
units: 'Units',
|
|
};
|
|
|
|
const PortfolioImportPage = () => {
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const canImportWorkbook = Boolean(currentUser?.app_role?.globalAccess || (currentUser && hasPermission(currentUser, createPermissions)));
|
|
|
|
const [workbookFile, setWorkbookFile] = useState<File | null>(null);
|
|
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false);
|
|
const [isImporting, setIsImporting] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [successMessage, setSuccessMessage] = useState('');
|
|
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
|
|
|
|
const workbookSummary = useMemo(() => {
|
|
if (!importResult?.summary) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(importResult.summary).filter(([, value]) => value.created > 0 || value.reused > 0);
|
|
}, [importResult]);
|
|
|
|
const downloadTemplate = async () => {
|
|
try {
|
|
setIsDownloadingTemplate(true);
|
|
setErrorMessage('');
|
|
|
|
const response = await axios.get('/corporate-stay-portal/portfolio-import-template', {
|
|
responseType: 'blob',
|
|
});
|
|
|
|
const blob = new Blob([response.data], {
|
|
type:
|
|
response.headers['content-type'] ||
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
});
|
|
|
|
const link = document.createElement('a');
|
|
link.href = window.URL.createObjectURL(blob);
|
|
link.download = 'portfolio-import-template.xlsx';
|
|
link.click();
|
|
window.URL.revokeObjectURL(link.href);
|
|
} catch (error: any) {
|
|
setErrorMessage(error?.response?.data || 'Failed to download the workbook template.');
|
|
} finally {
|
|
setIsDownloadingTemplate(false);
|
|
}
|
|
};
|
|
|
|
const submitWorkbook = async () => {
|
|
if (!workbookFile || !canImportWorkbook) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsImporting(true);
|
|
setErrorMessage('');
|
|
setSuccessMessage('');
|
|
setImportResult(null);
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', workbookFile);
|
|
formData.append('filename', workbookFile.name);
|
|
|
|
const response = await axios.post<ImportResponse>('/corporate-stay-portal/portfolio-import', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
});
|
|
|
|
setImportResult(response.data);
|
|
setSuccessMessage(`Workbook imported successfully: ${response.data.workbook.fileName}`);
|
|
setWorkbookFile(null);
|
|
} catch (error: any) {
|
|
setErrorMessage(error?.response?.data || 'Workbook import failed.');
|
|
} finally {
|
|
setIsImporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Portfolio Import Center')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={mdiHomeCity} title='Portfolio Import Center' main>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
<CardBox className='mb-6'>
|
|
<div className='space-y-4'>
|
|
<div>
|
|
<h2 className='text-lg font-semibold text-gray-900 dark:text-slate-100'>One workbook for portfolio onboarding</h2>
|
|
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
|
Upload a single Excel workbook to create linked tenants, organizations, properties, unit types, and units in dependency order.
|
|
Existing records are reused when the workbook references a matching ID or business key.
|
|
</p>
|
|
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>
|
|
This v1 workbook flow covers portfolio setup sheets only. Negotiated rates stay on their current flow for now.
|
|
</p>
|
|
</div>
|
|
|
|
{!canImportWorkbook && (
|
|
<div className='rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'>
|
|
You can review the template, but uploading requires at least one of these permissions: {createPermissions.join(', ')}.
|
|
</div>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<div className='rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100'>
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
|
|
{successMessage && (
|
|
<div className='rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100'>
|
|
{successMessage}
|
|
</div>
|
|
)}
|
|
|
|
<div className='flex flex-wrap gap-3'>
|
|
<BaseButton
|
|
color='info'
|
|
icon={mdiDownload}
|
|
label={isDownloadingTemplate ? 'Downloading...' : 'Download template'}
|
|
onClick={downloadTemplate}
|
|
disabled={isDownloadingTemplate}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
icon={mdiFileUploadOutline}
|
|
label={isImporting ? 'Importing workbook...' : 'Import workbook'}
|
|
onClick={submitWorkbook}
|
|
disabled={!workbookFile || !canImportWorkbook || isImporting}
|
|
/>
|
|
</div>
|
|
|
|
<DragDropFilePicker file={workbookFile} setFile={setWorkbookFile} formats={'.xlsx,.xls'} />
|
|
</div>
|
|
</CardBox>
|
|
|
|
{importResult && (
|
|
<CardBox className='mb-6'>
|
|
<div className='space-y-4'>
|
|
<div>
|
|
<h3 className='text-base font-semibold text-gray-900 dark:text-slate-100'>Latest import summary</h3>
|
|
<p className='mt-1 text-sm text-gray-600 dark:text-slate-300'>
|
|
Processed sheets:{' '}
|
|
{importResult.workbook.sheets.map((sheet) => `${sheet.name} (${sheet.rows})`).join(', ')}
|
|
</p>
|
|
</div>
|
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
|
|
{workbookSummary.map(([key, value]) => (
|
|
<div key={key} className='rounded-xl border border-gray-200 px-4 py-3 dark:border-slate-700'>
|
|
<div className='text-sm font-semibold text-gray-900 dark:text-slate-100'>{summaryLabels[key] || key}</div>
|
|
<div className='mt-2 text-sm text-gray-600 dark:text-slate-300'>Created: {value.created}</div>
|
|
<div className='text-sm text-gray-600 dark:text-slate-300'>Reused: {value.reused}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
)}
|
|
|
|
<div className='grid gap-6 xl:grid-cols-2'>
|
|
{portfolioWorkbookSheets.map((sheet) => {
|
|
const exampleCsvRow = sheet.columns.map((column) => sheet.exampleRow[column] ?? '').join('\t');
|
|
|
|
return (
|
|
<CardBox key={sheet.key} className='h-full'>
|
|
<div className='space-y-4'>
|
|
<div>
|
|
<h3 className='text-base font-semibold uppercase tracking-wide text-gray-900 dark:text-slate-100'>{sheet.label}</h3>
|
|
<p className='mt-2 text-sm text-gray-600 dark:text-slate-300'>{sheet.description}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>Required columns</div>
|
|
<p className='mt-1 text-sm text-gray-700 dark:text-slate-200'>{sheet.requiredColumns.join(', ')}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>All columns</div>
|
|
<p className='mt-1 break-words text-sm text-gray-700 dark:text-slate-200'>{sheet.columns.join(', ')}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className='text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-slate-400'>Example row</div>
|
|
<pre className='mt-2 overflow-x-auto rounded-xl bg-slate-900 px-4 py-3 text-xs text-slate-100'>
|
|
{`${sheet.columns.join('\t')}
|
|
${exampleCsvRow}`}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
</CardBox>
|
|
);
|
|
})}
|
|
</div>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
PortfolioImportPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
|
};
|
|
|
|
export default PortfolioImportPage;
|