39443-vm/frontend/src/pages/portfolio-import.tsx
2026-04-04 19:09:02 +00:00

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;