diff --git a/apps/api/src/modules/exports/routes/admin-export-routes.js b/apps/api/src/modules/exports/routes/admin-export-routes.js
index 14ef428..7f0946f 100644
--- a/apps/api/src/modules/exports/routes/admin-export-routes.js
+++ b/apps/api/src/modules/exports/routes/admin-export-routes.js
@@ -37,4 +37,15 @@ export async function registerAdminExportRoutes(app, options) {
.header('content-disposition', 'attachment; filename="inventory.csv"')
.send(data);
});
+
+ app.get('/exports/inventory.xls', async (request, reply) => {
+ const data = await adminExportService.inventoryExcel({
+ sellerId: request.admin.sellerId,
+ });
+
+ return reply
+ .type('application/vnd.ms-excel; charset=utf-8')
+ .header('content-disposition', 'attachment; filename="inventory.xls"')
+ .send(data);
+ });
}
diff --git a/apps/api/src/modules/exports/services/admin-export-service.js b/apps/api/src/modules/exports/services/admin-export-service.js
index d1efbed..35ad5ea 100644
--- a/apps/api/src/modules/exports/services/admin-export-service.js
+++ b/apps/api/src/modules/exports/services/admin-export-service.js
@@ -19,9 +19,34 @@ export function createAdminExportService({ inventoryItemService }) {
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
].join('\r\n');
},
+
+ async inventoryExcel({ sellerId }) {
+ const rows = await inventoryRows(sellerId);
+ const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
+ const header = columns.map((column) => excelCell(column, true)).join('');
+ const body = rows.map((row) => (
+ `${columns.map((column) => excelCell(row[column])).join('')}
`
+ )).join('');
+
+ return `\n\n`;
+ },
};
}
+function excelCell(value, header = false) {
+ const style = header ? ' ss:StyleID="Header"' : '';
+ return `${escapeXml(value)} | `;
+}
+
+function escapeXml(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
function escapeCsvValue(value) {
const text = value == null ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
diff --git a/apps/api/tests/application/admin-export-service.test.js b/apps/api/tests/application/admin-export-service.test.js
index ee3f462..292ec19 100644
--- a/apps/api/tests/application/admin-export-service.test.js
+++ b/apps/api/tests/application/admin-export-service.test.js
@@ -52,3 +52,18 @@ test('exports inventory as escaped CSV rows', async () => {
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
].join('\r\n'));
});
+
+test('exports inventory as an escaped Excel XML workbook', async () => {
+ const service = createAdminExportService({
+ inventoryItemService: {
+ async listBySellerId() {
+ return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE', notes: 'A ' }];
+ },
+ },
+ });
+
+ const workbook = await service.inventoryExcel({ sellerId: 'seller-1' });
+ assert.match(workbook, /Excel\.Sheet/);
+ assert.match(workbook, /A <note>/);
+ assert.match(workbook, /id<\/Data><\/Cell>/);
+});
diff --git a/apps/api/tests/http/admin-export-routes.test.js b/apps/api/tests/http/admin-export-routes.test.js
index 60170eb..5d935a5 100644
--- a/apps/api/tests/http/admin-export-routes.test.js
+++ b/apps/api/tests/http/admin-export-routes.test.js
@@ -80,3 +80,23 @@ test('downloads seller-scoped inventory CSV for an authenticated admin', async (
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
});
+
+test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => {
+ const app = buildApp({
+ authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
+ adminExportService: {
+ async inventoryExcel({ sellerId }) {
+ assert.equal(sellerId, 'seller-1');
+ return '';
+ },
+ },
+ });
+ testContext.after(() => app.close());
+
+ const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' });
+
+ assert.equal(response.statusCode, 200);
+ assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8');
+ assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"');
+ assert.equal(response.body, '');
+});
diff --git a/apps/web/src/pages/AdminHomePage.stories.js b/apps/web/src/pages/AdminHomePage.stories.js
index 5e3d9f8..3a9eb85 100644
--- a/apps/web/src/pages/AdminHomePage.stories.js
+++ b/apps/web/src/pages/AdminHomePage.stories.js
@@ -47,6 +47,17 @@ export const InventoryCsvExport = {
},
};
+export const InventoryExcelExport = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The admin workspace exposes a seller-scoped Excel-compatible inventory download.",
+ },
+ },
+ },
+};
+
export const BulkTagSelection = {
parameters: {
docs: {
diff --git a/apps/web/src/pages/AdminHomePage.vue b/apps/web/src/pages/AdminHomePage.vue
index 1967de6..dba1b09 100644
--- a/apps/web/src/pages/AdminHomePage.vue
+++ b/apps/web/src/pages/AdminHomePage.vue
@@ -508,6 +508,13 @@ onMounted(load);
>
Download inventory CSV
+
+ Download inventory Excel
+
|
Loading inventory...
diff --git a/docs/API.md b/docs/API.md
index 59723a4..8e7137a 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -101,6 +101,7 @@ POST /api/v1/admin/bulk
GET /api/v1/admin/dashboard
GET /api/v1/admin/exports/inventory.json
GET /api/v1/admin/exports/inventory.csv
+GET /api/v1/admin/exports/inventory.xls
GET /api/v1/admin/saved-views
POST /api/v1/admin/saved-views
diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md
index 11bbc9b..4330973 100644
--- a/docs/IMPLEMENTATION-STATUS.md
+++ b/docs/IMPLEMENTATION-STATUS.md
@@ -151,6 +151,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett
- Bulk archive, asynchronous bulk pricing refresh, and Product-level bulk tag updates are implemented through the seller-scoped API boundary; bulk status changes remain.
- Admin inventory JSON export is implemented at `GET /api/v1/admin/exports/inventory.json` and serializes seller-scoped admin DTOs as a downloadable attachment.
- Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs.
+- Admin inventory Excel-compatible export is implemented at `GET /api/v1/admin/exports/inventory.xls` using the XML workbook format and seller-scoped admin DTOs.
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
- `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.