Initial commit

This commit is contained in:
Ubuntu 2026-02-24 16:10:30 +00:00
commit 5db26aa10f
148 changed files with 27681 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
.next
.dist
build
coverage
.env
.env.*
apps/api/.env
apps/web/.env
apps/worker/.env
.DS_Store
pnpm-debug.log*

1
.npmrc Normal file
View File

@ -0,0 +1 @@
shared-workspace-lockfile=true

23
apps/api/README.md Normal file
View File

@ -0,0 +1,23 @@
# API (Express)
## Variables d'environnement
Créer un fichier `apps/api/.env` à partir de `.env.example`.
Exemple :
```
DATABASE_URL=postgresql://postgres@localhost:5432/localiztoi_stock
PORT=4000
```
## Démarrer
Depuis la racine du repo :
- `pnpm -F @localiztoi/api dev`
## Migrations (Drizzle)
- Générer : `pnpm -F @localiztoi/api db:generate`
- Appliquer : `pnpm -F @localiztoi/api db:migrate`

23
apps/api/dist/db/index.js vendored Normal file
View File

@ -0,0 +1,23 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
const { Pool } = pg;
let singletonPool = null;
export const getPool = () => singletonPool;
export const getDb = () => {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
if (!singletonPool) {
singletonPool = new Pool({
connectionString: databaseUrl,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
});
singletonPool.on('error', (err) => {
console.error('[db] Pool background error', err.message);
});
}
return drizzle(singletonPool);
};

View File

@ -0,0 +1,10 @@
import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const amazonPollLog = pgTable('amazon_poll_log', {
id: uuid('id').primaryKey().defaultRandom(),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
ordersFound: integer('orders_found').notNull().default(0),
ordersImported: integer('orders_imported').notNull().default(0),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});

15
apps/api/dist/db/schema/apiKeys.js vendored Normal file
View File

@ -0,0 +1,15 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
export const apiKeys = pgTable('api_keys', {
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(),
label: text('label').notNull(),
value: text('value').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
}, (t) => ({
providerLabelUniqueIdx: uniqueIndex('api_keys_provider_label_unique').on(t.provider, t.label)
}));

7
apps/api/dist/db/schema/index.js vendored Normal file
View File

@ -0,0 +1,7 @@
export * from './skuMappings.js';
export * from './orders.js';
export * from './orderItems.js';
export * from './orderImeis.js';
export * from './apiKeys.js';
export * from './amazonPollLog.js';
export * from './users.js';

27
apps/api/dist/db/schema/orderImeis.js vendored Normal file
View File

@ -0,0 +1,27 @@
import { integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { orders } from './orders.js';
export const orderImeis = pgTable('order_imeis', {
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
imei: text('imei').notNull(),
fotaModel: text('fota_model'),
fotaSerial: text('fota_serial'),
fotaCurrentFirmware: text('fota_current_firmware'),
fotaActivityStatus: integer('fota_activity_status'),
fotaSeenAt: text('fota_seen_at'),
fotaCompanyId: integer('fota_company_id'),
fotaCompanyName: text('fota_company_name'),
fotaGroupId: integer('fota_group_id'),
fotaGroupName: text('fota_group_name'),
fotaLookupError: text('fota_lookup_error'),
fotaLastLookupAt: timestamp('fota_last_lookup_at', { withTimezone: true }),
fotaMovedAt: timestamp('fota_moved_at', { withTimezone: true }),
fotaMoveError: text('fota_move_error'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow()
}, (t) => ({
orderImeiUniqueIdx: uniqueIndex('order_imeis_order_id_imei_unique').on(t.orderId, t.imei)
}));

15
apps/api/dist/db/schema/orderItems.js vendored Normal file
View File

@ -0,0 +1,15 @@
import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { orders } from './orders.js';
export const orderItems = pgTable('order_items', {
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
amazonSku: text('amazon_sku').notNull(),
amazonOrderItemId: text('amazon_order_item_id'),
quantity: integer('quantity').notNull(),
title: text('title'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow()
});

34
apps/api/dist/db/schema/orders.js vendored Normal file
View File

@ -0,0 +1,34 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
orderRef: text('order_ref').notNull(),
status: text('status').notNull().default('new'),
axonautDestockedAt: timestamp('axonaut_destocked_at', { withTimezone: true }),
axonautDestockError: text('axonaut_destock_error'),
colissimoTrackingNumber: text('colissimo_tracking_number'),
colissimoLabelUrl: text('colissimo_label_url'),
colissimoShippedAt: timestamp('colissimo_shipped_at', { withTimezone: true }),
colissimoError: text('colissimo_error'),
amazonTrackingConfirmedAt: timestamp('amazon_tracking_confirmed_at', { withTimezone: true }),
amazonTrackingError: text('amazon_tracking_error'),
shippingName: text('shipping_name'),
shippingFirstName: text('shipping_first_name'),
shippingLastName: text('shipping_last_name'),
shippingLine1: text('shipping_line1'),
shippingLine2: text('shipping_line2'),
shippingLine3: text('shipping_line3'),
shippingCity: text('shipping_city'),
shippingZipCode: text('shipping_zip_code'),
shippingCountryCode: text('shipping_country_code'),
shippingState: text('shipping_state'),
shippingPhone: text('shipping_phone'),
shippingAddressType: text('shipping_address_type'),
shippingFetchedAt: timestamp('shipping_fetched_at', { withTimezone: true }),
shippingFetchError: text('shipping_fetch_error'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
});

16
apps/api/dist/db/schema/skuMappings.js vendored Normal file
View File

@ -0,0 +1,16 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
export const skuMappings = pgTable('sku_mappings', {
id: uuid('id').primaryKey().defaultRandom(),
amazonSku: text('amazon_sku').notNull(),
enterpriseSku: text('enterprise_sku').notNull(),
expectedFotaModel: text('expected_fota_model').notNull(),
axonautProductInternalId: text('axonaut_product_internal_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
}, (t) => ({
amazonSkuUniqueIdx: uniqueIndex('sku_mappings_amazon_sku_unique').on(t.amazonSku)
}));

9
apps/api/dist/db/schema/users.js vendored Normal file
View File

@ -0,0 +1,9 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
displayName: text('display_name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});

2376
apps/api/dist/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

28
apps/api/dist/seed-admin.js vendored Normal file
View File

@ -0,0 +1,28 @@
import bcrypt from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { getDb } from './db/index.js';
import { users } from './db/schema/index.js';
const [, , username, password, displayName] = process.argv;
if (!username || !password || !displayName) {
console.error('Usage: pnpm -F @localiztoi/api seed:admin <username> <password> "<displayName>"');
process.exit(1);
}
const run = async () => {
const db = getDb();
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.username, username));
if (existing) {
console.log(`L'utilisateur "${username}" existe déjà.`);
process.exit(0);
}
const passwordHash = await bcrypt.hash(password, 12);
const [created] = await db
.insert(users)
.values({ username, passwordHash, displayName })
.returning();
console.log(`Admin créé : ${created.username} (${created.displayName}) — id: ${created.id}`);
process.exit(0);
};
run().catch((err) => {
console.error('Erreur:', err);
process.exit(1);
});

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema/*',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? ''
}
})

View File

@ -0,0 +1,11 @@
CREATE TABLE "sku_mappings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"amazon_sku" text NOT NULL,
"enterprise_sku" text NOT NULL,
"expected_fota_model" text NOT NULL,
"axonaut_product_internal_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "sku_mappings_amazon_sku_unique" ON "sku_mappings" USING btree ("amazon_sku");

View File

@ -0,0 +1,18 @@
CREATE TABLE "orders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_ref" text NOT NULL,
"status" text DEFAULT 'new' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "order_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_id" uuid NOT NULL,
"amazon_sku" text NOT NULL,
"quantity" integer NOT NULL,
"title" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -0,0 +1,9 @@
CREATE TABLE "order_imeis" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_id" uuid NOT NULL,
"imei" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "order_imeis" ADD CONSTRAINT "order_imeis_order_id_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "order_imeis_order_id_imei_unique" ON "order_imeis" USING btree ("order_id","imei");

View File

@ -0,0 +1,10 @@
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider" text NOT NULL,
"label" text NOT NULL,
"value" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "api_keys_provider_label_unique" ON "api_keys" USING btree ("provider","label");

View File

@ -0,0 +1,13 @@
ALTER TABLE "order_imeis" ADD COLUMN "fota_model" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_serial" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_current_firmware" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_activity_status" integer;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_seen_at" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_company_id" integer;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_company_name" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_group_id" integer;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_group_name" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_lookup_error" text;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_last_lookup_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_moved_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "order_imeis" ADD COLUMN "fota_move_error" text;

View File

@ -0,0 +1,2 @@
ALTER TABLE "orders" ADD COLUMN "axonaut_destocked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "axonaut_destock_error" text;

View File

@ -0,0 +1,4 @@
ALTER TABLE "orders" ADD COLUMN "colissimo_tracking_number" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "colissimo_label_url" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "colissimo_shipped_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "colissimo_error" text;

View File

@ -0,0 +1,3 @@
ALTER TABLE "orders" ADD COLUMN "amazon_tracking_confirmed_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "amazon_tracking_error" text;--> statement-breakpoint
ALTER TABLE "order_items" ADD COLUMN "amazon_order_item_id" text;

View File

@ -0,0 +1,9 @@
CREATE TABLE "amazon_poll_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"finished_at" timestamp with time zone,
"orders_found" integer DEFAULT 0 NOT NULL,
"orders_imported" integer DEFAULT 0 NOT NULL,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@ -0,0 +1,9 @@
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"username" text NOT NULL,
"password_hash" text NOT NULL,
"display_name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);

View File

@ -0,0 +1,14 @@
ALTER TABLE "orders" ADD COLUMN "shipping_name" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_first_name" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_last_name" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_line1" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_line2" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_line3" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_city" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_zip_code" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_country_code" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_state" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_phone" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_address_type" text;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_fetched_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "orders" ADD COLUMN "shipping_fetch_error" text;

View File

@ -0,0 +1,93 @@
{
"id": "1c5aa86b-4ec4-408e-92a0-4c32d375a8af",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,205 @@
{
"id": "0813536e-6aa4-4866-91ff-8439999a78dc",
"prevId": "1c5aa86b-4ec4-408e-92a0-4c32d375a8af",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,280 @@
{
"id": "23f10660-2add-4f0b-81d9-421b4088d310",
"prevId": "0813536e-6aa4-4866-91ff-8439999a78dc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,354 @@
{
"id": "a270275c-2536-41aa-8839-5de91e42e507",
"prevId": "23f10660-2add-4f0b-81d9-421b4088d310",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,432 @@
{
"id": "3268bf9a-9588-4a2a-a0f6-7693e6bdd52c",
"prevId": "a270275c-2536-41aa-8839-5de91e42e507",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,444 @@
{
"id": "64a0f718-3f97-4f0f-8c4b-29a43ba32f13",
"prevId": "3268bf9a-9588-4a2a-a0f6-7693e6bdd52c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,468 @@
{
"id": "9e83fede-35c3-417c-991d-7276404ecc1e",
"prevId": "64a0f718-3f97-4f0f-8c4b-29a43ba32f13",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_tracking_number": {
"name": "colissimo_tracking_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_label_url": {
"name": "colissimo_label_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_shipped_at": {
"name": "colissimo_shipped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"colissimo_error": {
"name": "colissimo_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,486 @@
{
"id": "30dc85d4-0af0-491a-a23d-8f8f1e8ef38b",
"prevId": "9e83fede-35c3-417c-991d-7276404ecc1e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_tracking_number": {
"name": "colissimo_tracking_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_label_url": {
"name": "colissimo_label_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_shipped_at": {
"name": "colissimo_shipped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"colissimo_error": {
"name": "colissimo_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_confirmed_at": {
"name": "amazon_tracking_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_error": {
"name": "amazon_tracking_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amazon_order_item_id": {
"name": "amazon_order_item_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,546 @@
{
"id": "4fd753b1-8217-4793-a994-33b8c7dacd68",
"prevId": "30dc85d4-0af0-491a-a23d-8f8f1e8ef38b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.amazon_poll_log": {
"name": "amazon_poll_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"orders_found": {
"name": "orders_found",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"orders_imported": {
"name": "orders_imported",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_tracking_number": {
"name": "colissimo_tracking_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_label_url": {
"name": "colissimo_label_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_shipped_at": {
"name": "colissimo_shipped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"colissimo_error": {
"name": "colissimo_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_confirmed_at": {
"name": "amazon_tracking_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_error": {
"name": "amazon_tracking_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amazon_order_item_id": {
"name": "amazon_order_item_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,606 @@
{
"id": "406c03e2-8698-493f-ba3e-ee8ca42062fe",
"prevId": "4fd753b1-8217-4793-a994-33b8c7dacd68",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.amazon_poll_log": {
"name": "amazon_poll_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"orders_found": {
"name": "orders_found",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"orders_imported": {
"name": "orders_imported",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_tracking_number": {
"name": "colissimo_tracking_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_label_url": {
"name": "colissimo_label_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_shipped_at": {
"name": "colissimo_shipped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"colissimo_error": {
"name": "colissimo_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_confirmed_at": {
"name": "amazon_tracking_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_error": {
"name": "amazon_tracking_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amazon_order_item_id": {
"name": "amazon_order_item_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,690 @@
{
"id": "4dab07de-4804-4198-8d86-01b7c2f9dcaa",
"prevId": "406c03e2-8698-493f-ba3e-ee8ca42062fe",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.amazon_poll_log": {
"name": "amazon_poll_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"orders_found": {
"name": "orders_found",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"orders_imported": {
"name": "orders_imported",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"api_keys_provider_label_unique": {
"name": "api_keys_provider_label_unique",
"columns": [
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "label",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sku_mappings": {
"name": "sku_mappings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enterprise_sku": {
"name": "enterprise_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expected_fota_model": {
"name": "expected_fota_model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"axonaut_product_internal_id": {
"name": "axonaut_product_internal_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sku_mappings_amazon_sku_unique": {
"name": "sku_mappings_amazon_sku_unique",
"columns": [
{
"expression": "amazon_sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_ref": {
"name": "order_ref",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"axonaut_destocked_at": {
"name": "axonaut_destocked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"axonaut_destock_error": {
"name": "axonaut_destock_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_tracking_number": {
"name": "colissimo_tracking_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_label_url": {
"name": "colissimo_label_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"colissimo_shipped_at": {
"name": "colissimo_shipped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"colissimo_error": {
"name": "colissimo_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_confirmed_at": {
"name": "amazon_tracking_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"amazon_tracking_error": {
"name": "amazon_tracking_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_name": {
"name": "shipping_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_first_name": {
"name": "shipping_first_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_last_name": {
"name": "shipping_last_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_line1": {
"name": "shipping_line1",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_line2": {
"name": "shipping_line2",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_line3": {
"name": "shipping_line3",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_city": {
"name": "shipping_city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_zip_code": {
"name": "shipping_zip_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_country_code": {
"name": "shipping_country_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_state": {
"name": "shipping_state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_phone": {
"name": "shipping_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_address_type": {
"name": "shipping_address_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shipping_fetched_at": {
"name": "shipping_fetched_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"shipping_fetch_error": {
"name": "shipping_fetch_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"amazon_sku": {
"name": "amazon_sku",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amazon_order_item_id": {
"name": "amazon_order_item_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"quantity": {
"name": "quantity",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_imeis": {
"name": "order_imeis",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"order_id": {
"name": "order_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"imei": {
"name": "imei",
"type": "text",
"primaryKey": false,
"notNull": true
},
"fota_model": {
"name": "fota_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_serial": {
"name": "fota_serial",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_current_firmware": {
"name": "fota_current_firmware",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_activity_status": {
"name": "fota_activity_status",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_seen_at": {
"name": "fota_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_company_id": {
"name": "fota_company_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_company_name": {
"name": "fota_company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_group_id": {
"name": "fota_group_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"fota_group_name": {
"name": "fota_group_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_lookup_error": {
"name": "fota_lookup_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fota_last_lookup_at": {
"name": "fota_last_lookup_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_moved_at": {
"name": "fota_moved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"fota_move_error": {
"name": "fota_move_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"order_imeis_order_id_imei_unique": {
"name": "order_imeis_order_id_imei_unique",
"columns": [
{
"expression": "order_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "imei",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"order_imeis_order_id_orders_id_fk": {
"name": "order_imeis_order_id_orders_id_fk",
"tableFrom": "order_imeis",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,83 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1771328005091,
"tag": "0000_parallel_darkstar",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1771329960889,
"tag": "0001_huge_senator_kelly",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1771330499929,
"tag": "0002_reflective_deathbird",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1771405468186,
"tag": "0003_lonely_turbo",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1771416713614,
"tag": "0004_naive_rattler",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1771432314879,
"tag": "0005_nostalgic_shape",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1771496458805,
"tag": "0006_pink_gorgon",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1771500645465,
"tag": "0007_lucky_karma",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1771525119636,
"tag": "0008_striped_morg",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1771527796416,
"tag": "0009_sweet_wrecker",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1771530682623,
"tag": "0010_short_king_bedlam",
"breakpoints": true
}
]
}

41
apps/api/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "@localiztoi/api",
"private": true,
"type": "module",
"scripts": {
"dev": "dotenv -e .env -- tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"db:generate": "dotenv -e .env -- drizzle-kit generate",
"db:migrate": "dotenv -e .env -- drizzle-kit migrate",
"db:push": "dotenv -e .env -- drizzle-kit push",
"seed:admin": "dotenv -e .env -- tsx src/seed-admin.ts",
"lint": "echo \"lint not configured yet\""
},
"dependencies": {
"@localiztoi/shared": "workspace:*",
"amazon-sp-api": "^1.2.0",
"axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"drizzle-orm": "^0.45.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.18.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.33",
"@types/pg": "^8.16.0",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.9",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

30
apps/api/src/db/index.ts Normal file
View File

@ -0,0 +1,30 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import pg from 'pg'
const { Pool } = pg
let singletonPool: pg.Pool | null = null
export const getPool = () => singletonPool
export const getDb = () => {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required')
}
if (!singletonPool) {
singletonPool = new Pool({
connectionString: databaseUrl,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000
})
singletonPool.on('error', (err) => {
console.error('[db] Pool background error', err.message)
})
}
return drizzle(singletonPool)
}

View File

@ -0,0 +1,11 @@
import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const amazonPollLog = pgTable('amazon_poll_log', {
id: uuid('id').primaryKey().defaultRandom(),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
ordersFound: integer('orders_found').notNull().default(0),
ordersImported: integer('orders_imported').notNull().default(0),
error: text('error'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})

View File

@ -0,0 +1,20 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'
export const apiKeys = pgTable(
'api_keys',
{
id: uuid('id').primaryKey().defaultRandom(),
provider: text('provider').notNull(),
label: text('label').notNull(),
value: text('value').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
},
(t) => ({
providerLabelUniqueIdx: uniqueIndex('api_keys_provider_label_unique').on(t.provider, t.label)
})
)

View File

@ -0,0 +1,7 @@
export * from './skuMappings.js'
export * from './orders.js'
export * from './orderItems.js'
export * from './orderImeis.js'
export * from './apiKeys.js'
export * from './amazonPollLog.js'
export * from './users.js'

View File

@ -0,0 +1,33 @@
import { integer, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'
import { orders } from './orders.js'
export const orderImeis = pgTable(
'order_imeis',
{
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
imei: text('imei').notNull(),
fotaModel: text('fota_model'),
fotaSerial: text('fota_serial'),
fotaCurrentFirmware: text('fota_current_firmware'),
fotaActivityStatus: integer('fota_activity_status'),
fotaSeenAt: text('fota_seen_at'),
fotaCompanyId: integer('fota_company_id'),
fotaCompanyName: text('fota_company_name'),
fotaGroupId: integer('fota_group_id'),
fotaGroupName: text('fota_group_name'),
fotaLookupError: text('fota_lookup_error'),
fotaLastLookupAt: timestamp('fota_last_lookup_at', { withTimezone: true }),
fotaMovedAt: timestamp('fota_moved_at', { withTimezone: true }),
fotaMoveError: text('fota_move_error'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow()
},
(t) => ({
orderImeiUniqueIdx: uniqueIndex('order_imeis_order_id_imei_unique').on(t.orderId, t.imei)
})
)

View File

@ -0,0 +1,17 @@
import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
import { orders } from './orders.js'
export const orderItems = pgTable('order_items', {
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.notNull()
.references(() => orders.id, { onDelete: 'cascade' }),
amazonSku: text('amazon_sku').notNull(),
amazonOrderItemId: text('amazon_order_item_id'),
quantity: integer('quantity').notNull(),
title: text('title'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow()
})

View File

@ -0,0 +1,35 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const orders = pgTable('orders', {
id: uuid('id').primaryKey().defaultRandom(),
orderRef: text('order_ref').notNull(),
status: text('status').notNull().default('new'),
axonautDestockedAt: timestamp('axonaut_destocked_at', { withTimezone: true }),
axonautDestockError: text('axonaut_destock_error'),
colissimoTrackingNumber: text('colissimo_tracking_number'),
colissimoLabelUrl: text('colissimo_label_url'),
colissimoShippedAt: timestamp('colissimo_shipped_at', { withTimezone: true }),
colissimoError: text('colissimo_error'),
amazonTrackingConfirmedAt: timestamp('amazon_tracking_confirmed_at', { withTimezone: true }),
amazonTrackingError: text('amazon_tracking_error'),
shippingName: text('shipping_name'),
shippingFirstName: text('shipping_first_name'),
shippingLastName: text('shipping_last_name'),
shippingLine1: text('shipping_line1'),
shippingLine2: text('shipping_line2'),
shippingLine3: text('shipping_line3'),
shippingCity: text('shipping_city'),
shippingZipCode: text('shipping_zip_code'),
shippingCountryCode: text('shipping_country_code'),
shippingState: text('shipping_state'),
shippingPhone: text('shipping_phone'),
shippingAddressType: text('shipping_address_type'),
shippingFetchedAt: timestamp('shipping_fetched_at', { withTimezone: true }),
shippingFetchError: text('shipping_fetch_error'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
})

View File

@ -0,0 +1,21 @@
import { pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'
export const skuMappings = pgTable(
'sku_mappings',
{
id: uuid('id').primaryKey().defaultRandom(),
amazonSku: text('amazon_sku').notNull(),
enterpriseSku: text('enterprise_sku').notNull(),
expectedFotaModel: text('expected_fota_model').notNull(),
axonautProductInternalId: text('axonaut_product_internal_id').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
},
(t) => ({
amazonSkuUniqueIdx: uniqueIndex('sku_mappings_amazon_sku_unique').on(t.amazonSku)
})
)

View File

@ -0,0 +1,10 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(),
displayName: text('display_name').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})

2736
apps/api/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
import { getDb } from './db/index.js'
import { users } from './db/schema/index.js'
const [, , username, password, displayName] = process.argv
if (!username || !password || !displayName) {
console.error('Usage: pnpm -F @localiztoi/api seed:admin <username> <password> "<displayName>"')
process.exit(1)
}
const run = async () => {
const db = getDb()
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.username, username))
if (existing) {
console.log(`L'utilisateur "${username}" existe déjà.`)
process.exit(0)
}
const passwordHash = await bcrypt.hash(password, 12)
const [created] = await db
.insert(users)
.values({ username, passwordHash, displayName })
.returning()
console.log(`Admin créé : ${created.username} (${created.displayName}) — id: ${created.id}`)
process.exit(0)
}
run().catch((err) => {
console.error('Erreur:', err)
process.exit(1)
})

16
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"types": ["node"],
"resolveJsonModule": true
},
"include": ["src"]
}

41
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
apps/web/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

23
apps/web/components.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

22
apps/web/next.config.ts Normal file
View File

@ -0,0 +1,22 @@
import type { NextConfig } from 'next'
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:4000'
const nextConfig: NextConfig = {
reactCompiler: true,
allowedDevOrigins: ['http://localhost:3000', 'http://localhost:3001', 'http://192.168.1.50:3000'],
async rewrites() {
return [
{
source: '/api/:path*',
destination: `${API_BASE_URL}/:path*`,
},
{
source: '/labels/:path*',
destination: `${API_BASE_URL}/labels/:path*`,
},
]
},
}
export default nextConfig

40
apps/web/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@localiztoi/shared": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html5-qrcode": "^2.3.8",
"lucide-react": "^0.574.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config

1
apps/web/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
apps/web/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,5 @@
import { AmazonOrdersPage } from '@/components/admin/AmazonOrdersPage'
export default function Page() {
return <AmazonOrdersPage />
}

View File

@ -0,0 +1,5 @@
import { ApiKeysPage } from '@/components/admin/ApiKeysPage'
export default function Page() {
return <ApiKeysPage />
}

View File

@ -0,0 +1,5 @@
import { AxonautStockPage } from '@/components/admin/AxonautStockPage'
export default function Page() {
return <AxonautStockPage />
}

View File

@ -0,0 +1,32 @@
'use client'
import type { ReactNode } from 'react'
import { useAuth } from '@/hooks/useAuth'
import { AdminShell } from '@/components/admin/AdminShell'
import { Skeleton } from '@/components/ui/skeleton'
export default function AdminLayout({ children }: { children: ReactNode }) {
const { user, loading, logout } = useAuth()
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="grid gap-3 w-64">
<Skeleton className="h-4 w-32 mx-auto" />
<Skeleton className="h-2 w-48 mx-auto" />
</div>
</div>
)
}
if (!user) {
return null
}
return (
<AdminShell user={user} onLogout={logout}>
{children}
</AdminShell>
)
}

View File

@ -0,0 +1,10 @@
import { OrderDetailsPage } from '@/components/admin/orders/OrderDetailsPage'
type Props = {
params: Promise<{ id: string }>
}
export default async function Page(props: Props) {
const { id } = await props.params
return <OrderDetailsPage id={id} />
}

View File

@ -0,0 +1,10 @@
import { ScanTrackingPage } from '@/components/admin/orders/ScanTrackingPage'
type Props = {
params: Promise<{ id: string }>
}
export default async function Page(props: Props) {
const { id } = await props.params
return <ScanTrackingPage id={id} />
}

View File

@ -0,0 +1,10 @@
import { ScanPage } from '@/components/admin/orders/ScanPage'
type Props = {
params: Promise<{ id: string }>
}
export default async function Page(props: Props) {
const { id } = await props.params
return <ScanPage id={id} />
}

View File

@ -0,0 +1,5 @@
import { NewOrderPage } from '@/components/admin/orders/NewOrderPage'
export default function Page() {
return <NewOrderPage />
}

View File

@ -0,0 +1,5 @@
import { OrdersListPage } from '@/components/admin/orders/OrdersListPage'
export default function Page() {
return <OrdersListPage />
}

View File

@ -0,0 +1,5 @@
import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage'
export default function AdminPage() {
return <AdminDashboardPage />
}

View File

@ -0,0 +1,5 @@
import { SkuMappingsPage } from '@/components/admin/SkuMappingsPage'
export default function Page() {
return <SkuMappingsPage />
}

View File

@ -0,0 +1,5 @@
import { UsersPage } from '@/components/admin/UsersPage'
export default function Page() {
return <UsersPage />
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,47 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Gestion Stock - Localiztoi",
description: "Gestion de stock et préparation de commandes",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body
className={cn(
geistSans.variable,
geistMono.variable,
"antialiased"
)}
>
{children}
<Toaster />
</body>
</html>
);
}

View File

@ -0,0 +1,5 @@
import { LoginPage } from '@/components/auth/LoginPage'
export default function Page() {
return <LoginPage />
}

View File

@ -0,0 +1,141 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

20
apps/web/src/app/page.tsx Normal file
View File

@ -0,0 +1,20 @@
import Link from 'next/link'
import { Package } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function Home() {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-6 p-6">
<div className="flex items-center gap-3">
<Package className="h-8 w-8" />
<h1 className="text-3xl font-bold tracking-tight">Localiztoi Stock</h1>
</div>
<p className="text-muted-foreground text-center">
Gestion de stock et préparation de commandes
</p>
<Button size="lg" asChild>
<Link href="/admin">Accéder à l&apos;administration</Link>
</Button>
</div>
)
}

View File

@ -0,0 +1,249 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { RefreshCw, Plus, Package, ArrowLeftRight, ScanLine, Radio } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
type OrderRow = {
id: string
status: string
createdAt: string
}
type PollStatus = {
enabled: boolean
intervalMs: number
pollRunning: boolean
lastPollAt: string | null
lastLog: {
startedAt: string
finishedAt: string | null
ordersFound: number
ordersImported: number
error: string | null
} | null
}
export const AdminDashboardPage = () => {
const [orders, setOrders] = useState<OrderRow[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pollStatus, setPollStatus] = useState<PollStatus | null>(null)
const [pollTriggering, setPollTriggering] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<OrderRow[]>('/admin/orders')
setOrders(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const loadPollStatus = async () => {
try {
const res = await api.get<PollStatus>('/admin/amazon/poll-status')
setPollStatus(res.data)
} catch {
// ignore
}
}
const triggerPoll = async () => {
setPollTriggering(true)
try {
await api.post('/admin/amazon/poll-now')
// Rafraîchir le statut après 3s (le temps que le poll s'exécute)
setTimeout(() => void loadPollStatus(), 3000)
} catch {
// ignore
} finally {
setPollTriggering(false)
}
}
useEffect(() => {
void load()
void loadPollStatus()
const interval = setInterval(() => void loadPollStatus(), 30_000)
return () => clearInterval(interval)
}, [])
const orderCount = orders.length
const newCount = useMemo(() => orders.filter((o) => o.status === 'new').length, [orders])
const lastCreatedAt = useMemo(() => {
const last = orders[0]?.createdAt
if (!last) {
return null
}
return new Date(last).toLocaleString()
}, [orders])
return (
<div className="grid gap-4 max-w-[1100px]">
<div className="flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Vue rapide sur l&apos;activité</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Commandes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{orderCount}</div>
{lastCreatedAt ? (
<p className="text-xs text-muted-foreground mt-1">Dernière: {lastCreatedAt}</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">À préparer</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{newCount}</div>
<p className="text-xs text-muted-foreground mt-1">Commandes en attente</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Scanner</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<ScanLine className="h-6 w-6 text-green-600" />
<span className="text-lg font-semibold text-green-600">Prêt</span>
</div>
<p className="text-xs text-muted-foreground mt-1">Scan QR/barcode dans le détail commande</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Raccourcis</CardTitle>
</CardHeader>
<CardContent className="grid gap-2">
<Button variant="outline" className="justify-start h-11" asChild>
<Link href="/admin/orders/new">
<Plus className="h-4 w-4 mr-2" />
Créer une commande
</Link>
</Button>
<Button variant="outline" className="justify-start h-11" asChild>
<Link href="/admin/orders">
<Package className="h-4 w-4 mr-2" />
Voir les commandes
</Link>
</Button>
<Button variant="outline" className="justify-start h-11" asChild>
<Link href="/admin/sku-mappings">
<ArrowLeftRight className="h-4 w-4 mr-2" />
Correspondances SKU
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<Radio className="h-4 w-4" />
Polling Amazon
</CardTitle>
{pollStatus ? (
<Badge variant={pollStatus.pollRunning ? 'default' : 'outline'}>
{pollStatus.pollRunning ? 'En cours…' : 'Actif'}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-3">
{pollStatus ? (
<>
<div className="grid gap-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Intervalle</span>
<span>{Math.round(pollStatus.intervalMs / 60000)} min</span>
</div>
{pollStatus.lastPollAt ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Dernier poll</span>
<span>{new Date(pollStatus.lastPollAt).toLocaleTimeString()}</span>
</div>
) : null}
{pollStatus.lastLog ? (
<>
<div className="flex justify-between">
<span className="text-muted-foreground">Trouvées</span>
<span>{pollStatus.lastLog.ordersFound}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Importées</span>
<span>{pollStatus.lastLog.ordersImported}</span>
</div>
{pollStatus.lastLog.error ? (
<p className="text-xs text-destructive mt-1 truncate">
{pollStatus.lastLog.error}
</p>
) : null}
</>
) : (
<p className="text-xs text-muted-foreground">Pas encore de résultat</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={pollTriggering || pollStatus.pollRunning}
onClick={() => void triggerPoll()}
>
{pollTriggering ? (
<><RefreshCw className="h-3 w-3 mr-1 animate-spin" /> Lancement</>
) : (
<><RefreshCw className="h-3 w-3 mr-1" /> Forcer le poll</>
)}
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">Chargement</p>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,162 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, type ReactNode } from 'react'
import { Menu, LayoutDashboard, Package, ArrowLeftRight, Plus, KeyRound, PackageSearch, ShoppingCart, LogOut, Users } from 'lucide-react'
import type { AuthUser } from '@localiztoi/shared'
import { Button } from '@/components/ui/button'
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
} from '@/components/ui/sheet'
import { cn } from '@/lib/utils'
const isActivePath = (pathname: string, href: string) => {
if (href === '/admin') {
return pathname === '/admin'
}
return pathname === href || pathname.startsWith(`${href}/`)
}
const navLinks = [
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/admin/amazon-orders', label: 'Amazon', icon: ShoppingCart },
{ href: '/admin/orders', label: 'Commandes', icon: Package },
{ href: '/admin/sku-mappings', label: 'Correspondances SKU', icon: ArrowLeftRight },
{ href: '/admin/axonaut-stock', label: 'Stock Axonaut', icon: PackageSearch },
{ href: '/admin/api-keys', label: 'Tokens API', icon: KeyRound },
{ href: '/admin/users', label: 'Utilisateurs', icon: Users },
]
const NavContent = ({
pathname,
onNavigate,
}: {
pathname: string
onNavigate?: () => void
}) => (
<nav className="flex flex-col gap-1 p-3">
{navLinks.map(({ href, label, icon: Icon }) => {
const active = isActivePath(pathname, href)
return (
<Button
key={href}
variant={active ? 'secondary' : 'ghost'}
className={cn('w-full justify-start h-11 gap-3', active && 'font-semibold')}
asChild
>
<Link href={href} onClick={onNavigate}>
<Icon className="h-4 w-4" />
{label}
</Link>
</Button>
)
})}
</nav>
)
export const AdminShell = ({ children, user, onLogout }: { children: ReactNode; user: AuthUser; onLogout: () => void }) => {
const pathname = usePathname()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// Pages en mode plein écran (sans header ni sidebar)
const isFullscreen = pathname.match(/\/admin\/orders\/[^/]+\/scan(-tracking)?$/)
if (isFullscreen) {
return (
<div className="h-screen overflow-hidden">
<main className="h-full overflow-y-auto p-4">
{children}
</main>
</div>
)
}
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden md:flex md:flex-col w-64 border-r bg-background shrink-0">
<div className="p-4 border-b">
<p className="font-bold text-base tracking-tight">Localiztoi Stock</p>
<p className="text-xs text-muted-foreground">Console Admin</p>
</div>
<div className="flex-1 overflow-y-auto">
<NavContent pathname={pathname} />
</div>
<div className="p-3 border-t">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.displayName}</p>
<p className="text-xs text-muted-foreground truncate">{user.username}</p>
</div>
<Button variant="ghost" size="icon" className="shrink-0 text-muted-foreground" onClick={onLogout} title="Déconnexion">
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</aside>
{/* Main area */}
<div className="flex flex-col flex-1 overflow-hidden">
{/* Header */}
<header className="h-14 border-b bg-background flex items-center justify-between px-4 shrink-0 sticky top-0 z-10">
<div className="flex items-center gap-3">
{/* Mobile hamburger */}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
<span className="sr-only">Ouvrir le menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0 flex flex-col">
<SheetTitle className="p-4 border-b font-bold text-base tracking-tight">
Localiztoi Stock
</SheetTitle>
<div className="flex-1 overflow-y-auto">
<NavContent
pathname={pathname}
onNavigate={() => setMobileMenuOpen(false)}
/>
</div>
<div className="p-3 border-t">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user.displayName}</p>
<p className="text-xs text-muted-foreground truncate">{user.username}</p>
</div>
<Button variant="ghost" size="icon" className="shrink-0 text-muted-foreground" onClick={() => { setMobileMenuOpen(false); onLogout() }} title="Déconnexion">
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</SheetContent>
</Sheet>
<span className="font-semibold md:hidden">Localiztoi</span>
<span className="hidden md:block text-sm text-muted-foreground">{pathname}</span>
</div>
<Button size="sm" asChild>
<Link href="/admin/orders/new">
<Plus className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Créer une commande</span>
<span className="sm:hidden">Nouvelle</span>
</Link>
</Button>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-4 md:p-6">
{children}
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,343 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { RefreshCw, Download, Check, Radio } from 'lucide-react'
import { toast } from 'sonner'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
type AmazonOrder = {
AmazonOrderId: string
PurchaseDate: string
OrderStatus: string
OrderTotal?: { CurrencyCode: string; Amount: string }
NumberOfItemsUnshipped: number
NumberOfItemsShipped: number
FulfillmentChannel: string
SalesChannel?: string
EarliestShipDate?: string
LatestShipDate?: string
}
const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
Unshipped: { label: 'Non expédiée', variant: 'default' },
PartiallyShipped: { label: 'Partielle', variant: 'secondary' },
Shipped: { label: 'Expédiée', variant: 'outline' },
Canceled: { label: 'Annulée', variant: 'destructive' },
Pending: { label: 'En attente', variant: 'secondary' },
}
export const AmazonOrdersPage = () => {
const router = useRouter()
const [amazonOrders, setAmazonOrders] = useState<AmazonOrder[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const [importing, setImporting] = useState<string | null>(null)
const [importedIds, setImportedIds] = useState<Set<string>>(new Set())
const [importError, setImportError] = useState<string | null>(null)
const [sseConnected, setSseConnected] = useState(false)
const loadRef = useRef<() => Promise<void>>(undefined)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<{ orders: AmazonOrder[]; count: number }>('/admin/amazon/orders')
setAmazonOrders(res.data.orders ?? [])
setLoaded(true)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
loadRef.current = load
// Auto-load au mount + SSE + fallback polling
useEffect(() => {
void load()
const eventSource = new EventSource('/api/admin/events')
eventSource.onopen = () => {
setSseConnected(true)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'amazon-order-imported') {
toast.info(`Nouvelle commande Amazon : ${data.orderRef}`)
void loadRef.current?.()
} else if (data.type === 'amazon-poll-complete') {
void loadRef.current?.()
}
} catch {
// ignore
}
}
eventSource.onerror = () => {
setSseConnected(false)
}
// Fallback polling 60s si SSE échoue
const fallbackInterval = setInterval(() => {
if (eventSource.readyState !== EventSource.OPEN) {
void loadRef.current?.()
}
}, 60_000)
return () => {
eventSource.close()
clearInterval(fallbackInterval)
}
}, [])
const importOrder = async (amazonOrderId: string) => {
setImporting(amazonOrderId)
setImportError(null)
try {
const res = await api.post<{ orderId: string }>('/admin/amazon/import', { amazonOrderId })
setImportedIds((prev) => new Set(prev).add(amazonOrderId))
// Rediriger vers la commande importée après 1s
const orderId = res.data.orderId
setTimeout(() => {
router.push(`/admin/orders/${orderId}`)
}, 1000)
} catch (err) {
const msg = ensureErrorMessage(err)
if (msg.includes('order_already_exists')) {
setImportedIds((prev) => new Set(prev).add(amazonOrderId))
setImportError(`La commande ${amazonOrderId} existe déjà.`)
} else {
setImportError(msg)
}
} finally {
setImporting(null)
}
}
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return iso
}
}
const formatShipDeadline = (order: AmazonOrder) => {
if (!order.LatestShipDate) return null
try {
const deadline = new Date(order.LatestShipDate)
const now = new Date()
const diffMs = deadline.getTime() - now.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffHours < 0) return 'Dépassée'
if (diffHours < 24) return `${diffHours}h`
return `${Math.floor(diffHours / 24)}j`
} catch {
return null
}
}
return (
<div className="grid gap-4 max-w-[1000px]">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Commandes Amazon</h1>
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
Commandes à expédier depuis Amazon Seller Central
<span className={`inline-flex items-center gap-1 text-xs ${sseConnected ? 'text-green-600' : 'text-muted-foreground'}`}>
<Radio className="h-3 w-3" />
{sseConnected ? 'Temps réel' : 'Hors ligne'}
</span>
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{importError ? (
<Alert variant="destructive">
<AlertDescription>{importError}</AlertDescription>
</Alert>
) : null}
{loading && !loaded ? (
<div className="grid gap-3">
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
</div>
) : null}
{loaded ? (
<Card>
<CardHeader>
<CardTitle className="text-base">
Commandes ({amazonOrders.length})
</CardTitle>
</CardHeader>
<CardContent>
{amazonOrders.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
Aucune commande non expédiée trouvée sur les 7 derniers jours.
</p>
) : (
<>
{/* Mobile: cartes */}
<div className="grid gap-3 sm:hidden">
{amazonOrders.map((order) => {
const imported = importedIds.has(order.AmazonOrderId)
const isImporting = importing === order.AmazonOrderId
const status = statusLabels[order.OrderStatus]
const shipDeadline = formatShipDeadline(order)
return (
<div key={order.AmazonOrderId} className="border rounded-lg p-3 grid gap-2">
<div className="flex justify-between items-start">
<div>
<p className="font-mono text-sm font-medium">{order.AmazonOrderId}</p>
<p className="text-xs text-muted-foreground">{formatDate(order.PurchaseDate)}</p>
</div>
<Badge variant={status?.variant ?? 'outline'}>
{status?.label ?? order.OrderStatus}
</Badge>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>{order.NumberOfItemsUnshipped} article{order.NumberOfItemsUnshipped > 1 ? 's' : ''}</span>
{order.OrderTotal ? (
<span>{order.OrderTotal.Amount} {order.OrderTotal.CurrencyCode}</span>
) : null}
{shipDeadline ? <span>Expédier : {shipDeadline}</span> : null}
</div>
<Button
size="sm"
variant={imported ? 'outline' : 'default'}
disabled={imported || isImporting}
onClick={() => void importOrder(order.AmazonOrderId)}
className="w-full h-11"
>
{imported ? (
<><Check className="h-4 w-4 mr-1" /> Importée</>
) : isImporting ? (
<><RefreshCw className="h-4 w-4 mr-1 animate-spin" /> Import</>
) : (
<><Download className="h-4 w-4 mr-1" /> Importer</>
)}
</Button>
</div>
)
})}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Commande</TableHead>
<TableHead>Date</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Articles</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead>Expédier avant</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{amazonOrders.map((order) => {
const imported = importedIds.has(order.AmazonOrderId)
const isImporting = importing === order.AmazonOrderId
const status = statusLabels[order.OrderStatus]
const shipDeadline = formatShipDeadline(order)
return (
<TableRow key={order.AmazonOrderId}>
<TableCell className="font-mono text-sm">{order.AmazonOrderId}</TableCell>
<TableCell className="text-sm">{formatDate(order.PurchaseDate)}</TableCell>
<TableCell>
<Badge variant={status?.variant ?? 'outline'}>
{status?.label ?? order.OrderStatus}
</Badge>
</TableCell>
<TableCell className="text-right">{order.NumberOfItemsUnshipped}</TableCell>
<TableCell className="text-right text-sm">
{order.OrderTotal ? `${order.OrderTotal.Amount} ${order.OrderTotal.CurrencyCode}` : '—'}
</TableCell>
<TableCell className="text-sm">
{shipDeadline ? (
<Badge variant={shipDeadline === 'Dépassée' ? 'destructive' : 'outline'}>
{shipDeadline}
</Badge>
) : '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant={imported ? 'outline' : 'default'}
disabled={imported || isImporting}
onClick={() => void importOrder(order.AmazonOrderId)}
>
{imported ? (
<><Check className="h-4 w-4 mr-1" /> Importée</>
) : isImporting ? (
<><RefreshCw className="h-4 w-4 mr-1 animate-spin" /> Import</>
) : (
<><Download className="h-4 w-4 mr-1" /> Importer</>
)}
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@ -0,0 +1,379 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { RefreshCw, Plus, Trash2, Pencil, Key } from 'lucide-react'
import type { ApiProvider, ApiKeyCreate } from '@localiztoi/shared'
import { apiProviders, apiProviderLabels } from '@localiztoi/shared'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
type ApiKeyRecord = {
id: string
provider: ApiProvider
label: string
value: string
createdAt: string
updatedAt: string
}
const suggestedLabels: Partial<Record<ApiProvider, string[]>> = {
amazon: ['client_id', 'client_secret', 'refresh_token'],
axonaut: ['api_key'],
fota_teltonika: ['api_token', 'user_agent'],
'1nce': ['api_token'],
laposte: ['contract_number', 'password'],
}
export const ApiKeysPage = () => {
const [items, setItems] = useState<ApiKeyRecord[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Form state
const [formProvider, setFormProvider] = useState<ApiProvider>('amazon')
const [formLabel, setFormLabel] = useState('')
const [formCustomLabel, setFormCustomLabel] = useState('')
const [formValue, setFormValue] = useState('')
// Edit state
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const effectiveLabel = formLabel === '__custom' ? formCustomLabel : formLabel
const canSubmit = useMemo(() => {
const label = formLabel === '__custom' ? formCustomLabel : formLabel
return label.trim().length > 0 && formValue.trim().length > 0
}, [formLabel, formCustomLabel, formValue])
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<ApiKeyRecord[]>('/admin/api-keys')
setItems(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const onCreate = async () => {
if (!canSubmit) return
setLoading(true)
setError(null)
try {
const payload: ApiKeyCreate = {
provider: formProvider,
label: effectiveLabel.trim(),
value: formValue.trim()
}
await api.post('/admin/api-keys', payload)
setFormLabel('')
setFormCustomLabel('')
setFormValue('')
await load()
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const onUpdate = async (id: string) => {
if (!editValue.trim()) return
setLoading(true)
setError(null)
try {
await api.patch(`/admin/api-keys/${id}`, { value: editValue.trim() })
setEditingId(null)
setEditValue('')
await load()
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const onDelete = async (id: string) => {
setLoading(true)
setError(null)
try {
await api.delete(`/admin/api-keys/${id}`)
await load()
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
// Grouper par provider
const grouped = useMemo(() => {
const map = new Map<ApiProvider, ApiKeyRecord[]>()
for (const p of apiProviders) {
map.set(p, [])
}
for (const item of items) {
const list = map.get(item.provider)
if (list) list.push(item)
}
return map
}, [items])
// Labels suggérés non encore ajoutés pour le provider sélectionné
const availableSuggestions = useMemo(() => {
const suggestions = suggestedLabels[formProvider] ?? []
const existingLabels = (grouped.get(formProvider) ?? []).map((k) => k.label)
return suggestions.filter((s) => !existingLabels.includes(s))
}, [formProvider, grouped])
return (
<div className="grid gap-4 max-w-[900px]">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Tokens API</h1>
<p className="text-sm text-muted-foreground mt-1">
Clés et tokens pour les services externes
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{/* Formulaire d'ajout */}
<Card>
<CardHeader>
<CardTitle className="text-base">Ajouter un token</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="provider">Service</Label>
<select
id="provider"
value={formProvider}
onChange={(e) => {
setFormProvider(e.target.value as ApiProvider)
setFormLabel('')
setFormCustomLabel('')
}}
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{apiProviders.map((p) => (
<option key={p} value={p}>{apiProviderLabels[p]}</option>
))}
</select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="keyLabel">Nom de la clé</Label>
{availableSuggestions.length > 0 ? (
<select
id="keyLabel"
value={formLabel}
onChange={(e) => setFormLabel(e.target.value)}
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Sélectionner</option>
{availableSuggestions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
<option value="__custom">Autre (saisie libre)</option>
</select>
) : (
<Input
id="keyLabel"
value={formLabel}
onChange={(e) => setFormLabel(e.target.value)}
placeholder="ex: api_key"
className="h-11"
/>
)}
{formLabel === '__custom' ? (
<Input
value={formCustomLabel}
onChange={(e) => setFormCustomLabel(e.target.value)}
placeholder="Saisir le nom de la clé"
className="h-11"
autoFocus
/>
) : null}
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="keyValue">Valeur</Label>
<Input
id="keyValue"
type="password"
value={formValue}
onChange={(e) => setFormValue(e.target.value)}
placeholder="Coller le token ici"
className="h-11"
/>
</div>
<Button
onClick={() => void onCreate()}
disabled={!canSubmit || loading}
className="h-11 w-full sm:w-auto"
>
<Plus className="h-4 w-4 mr-1" />
Ajouter
</Button>
</CardContent>
</Card>
{/* Liste par provider */}
{loading && items.length === 0 ? (
<div className="grid gap-3">
<Skeleton className="h-32 rounded-lg" />
<Skeleton className="h-32 rounded-lg" />
</div>
) : (
apiProviders.map((provider) => {
const keys = grouped.get(provider) ?? []
return (
<Card key={provider}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-4 w-4" />
{apiProviderLabels[provider]}
{keys.length > 0 ? (
<Badge variant="secondary" className="ml-1">{keys.length}</Badge>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
{keys.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun token configuré</p>
) : (
<div className="grid gap-2">
{keys.map((item) => (
<div key={item.id} className="flex items-center gap-3 p-3 border rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{item.label}</p>
{editingId === item.id ? (
<div className="flex gap-2 mt-2">
<Input
type="password"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder="Nouvelle valeur"
className="h-9 text-sm"
autoFocus
/>
<Button
size="sm"
onClick={() => void onUpdate(item.id)}
disabled={loading || !editValue.trim()}
>
OK
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingId(null)
setEditValue('')
}}
>
Annuler
</Button>
</div>
) : (
<p className="text-xs font-mono text-muted-foreground mt-0.5">
{item.value}
</p>
)}
</div>
{editingId !== item.id ? (
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditingId(item.id)
setEditValue('')
}}
disabled={loading}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" disabled={loading}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce token ?</AlertDialogTitle>
<AlertDialogDescription>
Le token <strong>{item.label}</strong> de {apiProviderLabels[provider]} sera définitivement supprimé.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void onDelete(item.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : null}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
})
)}
</div>
)
}

View File

@ -0,0 +1,202 @@
'use client'
import { useState } from 'react'
import { RefreshCw, Package } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
type AxonautProduct = {
id: number
internal_id: string | null
product_code: string
name: string
description: string | null
price: number | null
tax_rate: number
type: number
disabled: boolean
stock: string | number | null
}
const typeLabels: Record<number, string> = {
601: 'Matière première',
701: 'Produit fini',
706: 'Service',
707: 'Marchandise',
}
export const AxonautStockPage = () => {
const [products, setProducts] = useState<AxonautProduct[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<AxonautProduct[]>('/admin/axonaut/products')
const data = Array.isArray(res.data) ? res.data : []
setProducts(data.filter((p) => !p.disabled))
setLoaded(true)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const parseStock = (stock: string | number | null | undefined): number | null => {
if (stock === null || stock === undefined) return null
const n = typeof stock === 'number' ? stock : parseFloat(stock)
return Number.isFinite(n) ? Math.round(n) : null
}
return (
<div className="grid gap-4 max-w-[900px]">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Stock Axonaut</h1>
<p className="text-sm text-muted-foreground mt-1">
Consultation des niveaux de stock dans Axonaut
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{loaded ? 'Rafraîchir' : 'Charger'}
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!loaded && !loading ? (
<Card>
<CardContent className="py-12 text-center">
<Package className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Cliquez sur Charger pour consulter les stocks Axonaut.
</p>
</CardContent>
</Card>
) : null}
{loading && !loaded ? (
<div className="grid gap-3">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
) : null}
{loaded ? (
<Card>
<CardHeader>
<CardTitle className="text-base">
Produits ({products.length})
</CardTitle>
</CardHeader>
<CardContent>
{/* Mobile: cartes */}
<div className="grid gap-2 sm:hidden">
{products.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">Aucun produit trouvé.</p>
) : (
products.map((p) => {
const stock = parseStock(p.stock)
return (
<div key={p.id} className="border rounded-lg p-3 grid gap-1">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.product_code ? (
<p className="text-xs text-muted-foreground font-mono">{p.product_code}</p>
) : null}
</div>
<Badge
variant={stock !== null && stock > 0 ? 'secondary' : 'outline'}
className={stock !== null && stock <= 0 ? 'text-destructive' : ''}
>
{stock !== null ? stock : '—'}
</Badge>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>{typeLabels[p.type] ?? `Type ${p.type}`}</span>
<span>{typeof p.price === 'number' ? p.price.toFixed(2) : '—'} EUR</span>
</div>
</div>
)
})
)}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Produit</TableHead>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Prix HT</TableHead>
<TableHead className="text-right">Stock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Aucun produit trouvé.
</TableCell>
</TableRow>
) : (
products.map((p) => {
const stock = parseStock(p.stock)
return (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="font-mono text-sm">{p.product_code || '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{typeLabels[p.type] ?? `Type ${p.type}`}
</TableCell>
<TableCell className="text-right">{typeof p.price === 'number' ? p.price.toFixed(2) : '—'} EUR</TableCell>
<TableCell className="text-right">
<Badge
variant={stock !== null && stock > 0 ? 'secondary' : 'outline'}
className={stock !== null && stock <= 0 ? 'text-destructive' : ''}
>
{stock !== null ? stock : '—'}
</Badge>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@ -0,0 +1,324 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { RefreshCw, Plus, Trash2 } from 'lucide-react'
import type { SkuMapping } from '@localiztoi/shared'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
type SkuMappingRecord = SkuMapping & { id: string }
type FormState = SkuMapping
const emptyForm: FormState = {
amazonSku: '',
enterpriseSku: '',
expectedFotaModel: '',
axonautProductInternalId: ''
}
export const SkuMappingsPage = () => {
const [items, setItems] = useState<SkuMappingRecord[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const canSubmit = useMemo(() => {
return Boolean(
form.amazonSku.trim() &&
form.enterpriseSku.trim() &&
form.expectedFotaModel.trim() &&
form.axonautProductInternalId.trim()
)
}, [form])
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<SkuMappingRecord[]>('/admin/sku-mappings')
setItems(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const onCreate = async () => {
if (!canSubmit) {
return
}
setLoading(true)
setError(null)
try {
await api.post('/admin/sku-mappings', form)
setForm(emptyForm)
await load()
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const onDelete = async (id: string) => {
setLoading(true)
setError(null)
try {
await api.delete(`/admin/sku-mappings/${id}`)
await load()
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
return (
<div className="grid gap-4 max-w-[1000px]">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Correspondances SKU</h1>
<p className="text-sm text-muted-foreground mt-1">
Amazon SKU SKU entreprise Modèle FOTA Produit Axonaut
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{/* Formulaire d'ajout */}
<Card>
<CardHeader>
<CardTitle className="text-base">Ajouter une correspondance</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="amazonSku">Amazon SKU</Label>
<Input
id="amazonSku"
value={form.amazonSku}
onChange={(e) => setForm({ ...form, amazonSku: e.target.value })}
placeholder="ex: FMC920-EU"
className="h-11"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="enterpriseSku">SKU entreprise</Label>
<Input
id="enterpriseSku"
value={form.enterpriseSku}
onChange={(e) => setForm({ ...form, enterpriseSku: e.target.value })}
placeholder="ex: FMC920-EU"
className="h-11"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="fotaModel">Modèle FOTA attendu</Label>
<Input
id="fotaModel"
value={form.expectedFotaModel}
onChange={(e) => setForm({ ...form, expectedFotaModel: e.target.value })}
placeholder="ex: FMC920"
className="h-11"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="axonautId">Réf Axonaut</Label>
<Input
id="axonautId"
value={form.axonautProductInternalId}
onChange={(e) => setForm({ ...form, axonautProductInternalId: e.target.value })}
placeholder="ex: 12345"
className="h-11"
/>
</div>
</div>
<Button
onClick={() => void onCreate()}
disabled={!canSubmit || loading}
className="h-11 w-full sm:w-auto"
>
<Plus className="h-4 w-4 mr-1" />
Ajouter
</Button>
</CardContent>
</Card>
{/* Liste */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Liste ({items.length})
</CardTitle>
</CardHeader>
<CardContent>
{/* Mobile: cartes */}
<div className="grid gap-2 sm:hidden">
{loading && items.length === 0 ? (
<>
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">Aucune correspondance.</p>
) : (
items.map((it) => (
<div key={it.id} className="border rounded-lg p-3 grid gap-1">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-sm">{it.amazonSku}</p>
<p className="text-xs text-muted-foreground"> {it.enterpriseSku}</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" disabled={loading}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cette correspondance ?</AlertDialogTitle>
<AlertDialogDescription>
La correspondance pour <strong>{it.amazonSku}</strong> sera définitivement supprimée.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void onDelete(it.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>FOTA: {it.expectedFotaModel}</span>
<span>Réf Axonaut: {it.axonautProductInternalId}</span>
</div>
</div>
))
)}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Amazon SKU</TableHead>
<TableHead>SKU entreprise</TableHead>
<TableHead>Modèle FOTA</TableHead>
<TableHead>Réf Axonaut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<>
{[1, 2].map((i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-8 w-20 ml-auto" /></TableCell>
</TableRow>
))}
</>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
Aucune correspondance.
</TableCell>
</TableRow>
) : (
items.map((it) => (
<TableRow key={it.id}>
<TableCell className="font-medium">{it.amazonSku}</TableCell>
<TableCell>{it.enterpriseSku}</TableCell>
<TableCell>{it.expectedFotaModel}</TableCell>
<TableCell>{it.axonautProductInternalId}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={loading}>
<Trash2 className="h-4 w-4 text-destructive mr-1" />
Supprimer
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cette correspondance ?</AlertDialogTitle>
<AlertDialogDescription>
La correspondance pour <strong>{it.amazonSku}</strong> sera définitivement supprimée.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void onDelete(it.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,333 @@
'use client'
import { useEffect, useState } from 'react'
import { RefreshCw, Plus, Trash2, KeyRound } from 'lucide-react'
import { api } from '@/config/api'
import { useAuth } from '@/hooks/useAuth'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
type UserRecord = {
id: string
username: string
displayName: string
createdAt: string
}
export const UsersPage = () => {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<UserRecord[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
// Form state
const [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formDisplayName, setFormDisplayName] = useState('')
// Change password state
const [changingPasswordId, setChangingPasswordId] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
const canSubmit = formUsername.trim().length >= 3 && formPassword.length >= 8 && formDisplayName.trim().length > 0
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<UserRecord[]>('/admin/users')
setUsers(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const onCreate = async () => {
if (!canSubmit) return
setLoading(true)
setError(null)
setSuccess(null)
try {
await api.post('/admin/users', {
username: formUsername.trim(),
password: formPassword,
displayName: formDisplayName.trim()
})
setFormUsername('')
setFormPassword('')
setFormDisplayName('')
setSuccess('Utilisateur créé')
await load()
} catch (err) {
const msg = ensureErrorMessage(err)
if (msg.includes('username_taken')) {
setError('Ce nom d\'utilisateur est déjà pris.')
} else {
setError(msg)
}
} finally {
setLoading(false)
}
}
const onChangePassword = async (id: string) => {
if (newPassword.length < 8) return
setLoading(true)
setError(null)
setSuccess(null)
try {
await api.patch(`/admin/users/${id}/password`, { password: newPassword })
setChangingPasswordId(null)
setNewPassword('')
setSuccess('Mot de passe modifié')
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const onDelete = async (id: string) => {
setLoading(true)
setError(null)
setSuccess(null)
try {
await api.delete(`/admin/users/${id}`)
setSuccess('Utilisateur supprimé')
await load()
} catch (err) {
const msg = ensureErrorMessage(err)
if (msg.includes('cannot_delete_self')) {
setError('Vous ne pouvez pas supprimer votre propre compte.')
} else {
setError(msg)
}
} finally {
setLoading(false)
}
}
return (
<div className="grid gap-4 max-w-[900px]">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Utilisateurs</h1>
<p className="text-sm text-muted-foreground mt-1">
Gérer les comptes utilisateurs
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{success ? (
<Alert>
<AlertDescription>{success}</AlertDescription>
</Alert>
) : null}
{/* Formulaire d'ajout */}
<Card>
<CardHeader>
<CardTitle className="text-base">Créer un utilisateur</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="username">Nom d&apos;utilisateur</Label>
<Input
id="username"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
placeholder="min. 3 caractères"
autoCapitalize="none"
autoCorrect="off"
className="h-12"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="displayName">Nom d&apos;affichage</Label>
<Input
id="displayName"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="Prénom ou surnom"
className="h-12"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
placeholder="min. 8 caractères"
className="h-12"
/>
</div>
</div>
<Button
onClick={() => void onCreate()}
disabled={!canSubmit || loading}
className="h-12 w-full sm:w-auto"
>
<Plus className="h-4 w-4 mr-1" />
Créer
</Button>
</CardContent>
</Card>
{/* Liste des utilisateurs */}
{loading && users.length === 0 ? (
<div className="grid gap-3">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base">
Comptes ({users.length})
</CardTitle>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun utilisateur</p>
) : (
<div className="grid gap-2">
{users.map((u) => {
const isSelf = currentUser?.id === u.id
return (
<div key={u.id} className="flex items-center gap-3 p-3 border rounded-lg">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
{u.displayName}
{isSelf ? (
<span className="text-xs text-muted-foreground ml-2">(vous)</span>
) : null}
</p>
<p className="text-xs text-muted-foreground">{u.username}</p>
{changingPasswordId === u.id ? (
<div className="flex gap-2 mt-2">
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Nouveau mot de passe (min. 8)"
className="h-9 text-sm"
autoFocus
/>
<Button
size="sm"
onClick={() => void onChangePassword(u.id)}
disabled={loading || newPassword.length < 8}
>
OK
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setChangingPasswordId(null)
setNewPassword('')
}}
>
Annuler
</Button>
</div>
) : null}
</div>
{changingPasswordId !== u.id ? (
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setChangingPasswordId(u.id)
setNewPassword('')
}}
disabled={loading}
title="Changer le mot de passe"
>
<KeyRound className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" disabled={loading || isSelf} title={isSelf ? 'Vous ne pouvez pas supprimer votre propre compte' : 'Supprimer'}>
<Trash2 className={`h-4 w-4 ${isSelf ? 'text-muted-foreground' : 'text-destructive'}`} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cet utilisateur ?</AlertDialogTitle>
<AlertDialogDescription>
Le compte <strong>{u.displayName}</strong> ({u.username}) sera définitivement supprimé.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void onDelete(u.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : null}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,113 @@
'use client'
import { Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import type { FotaLookupResult } from './types'
import { getFotaActivityStatusLabel } from './types'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
lookupResult: FotaLookupResult | null
saving: boolean
saveError: string | null
expectedFotaModels: Set<string>
onConfirm: () => void
onDismiss: () => void
}
export function ImeiValidationModal({
open,
onOpenChange,
lookupResult,
saving,
saveError,
expectedFotaModels,
onConfirm,
onDismiss,
}: Props) {
if (!lookupResult) return null
const model = (lookupResult.fotaModel ?? '').trim()
const modelMismatch =
expectedFotaModels.size > 0 && model !== '' && !expectedFotaModels.has(model)
const statusLabel = getFotaActivityStatusLabel(lookupResult.fotaActivityStatus)
const rows: Array<{ label: string; value: string | null }> = [
{ label: 'IMEI', value: lookupResult.imei },
{ label: 'Modele', value: lookupResult.fotaModel },
{ label: 'Serial', value: lookupResult.fotaSerial },
{ label: 'Firmware', value: lookupResult.fotaCurrentFirmware },
{ label: 'Statut', value: statusLabel },
{ label: 'Vu le', value: lookupResult.fotaSeenAt },
{ label: 'Organisation', value: lookupResult.fotaCompanyName },
{ label: 'Groupe', value: lookupResult.fotaGroupName },
]
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>Appareil detecte</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
Informations FOTA de l&apos;appareil scanne
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5">
{rows.map((row) =>
row.value ? (
<div key={row.label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{row.label}</span>
<span className="font-mono text-right max-w-[60%] truncate">
{row.value}
</span>
</div>
) : null
)}
</div>
{modelMismatch && (
<Alert variant="destructive" className="mt-2">
<AlertDescription>
Modele FOTA ({model}) different du modele attendu (
{Array.from(expectedFotaModels).join(', ')})
</AlertDescription>
</Alert>
)}
{saveError && (
<Alert variant="destructive" className="mt-2">
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
<AlertDialogFooter className="mt-4">
<Button variant="outline" onClick={onDismiss} disabled={saving}>
Recommencer
</Button>
<Button onClick={onConfirm} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validation...
</>
) : (
'Valider'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -0,0 +1,198 @@
'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { ArrowLeft, Plus, Trash2, Check } from 'lucide-react'
import type { OrderCreate } from '@localiztoi/shared'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
type ItemState = {
amazonSku: string
quantity: string
title: string
}
const emptyItem: ItemState = {
amazonSku: '',
quantity: '1',
title: ''
}
export const NewOrderPage = () => {
const [orderRef, setOrderRef] = useState('')
const [items, setItems] = useState<ItemState[]>([{ ...emptyItem }])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createdId, setCreatedId] = useState<string | null>(null)
const canSubmit = useMemo(() => {
if (!orderRef.trim()) {
return false
}
if (items.length === 0) {
return false
}
return items.every((it) => it.amazonSku.trim() && Number(it.quantity) > 0)
}, [orderRef, items])
const setItem = (idx: number, patch: Partial<ItemState>) => {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)))
}
const addItem = () => {
setItems((prev) => [...prev, { ...emptyItem }])
}
const removeItem = (idx: number) => {
setItems((prev) => prev.filter((_it, i) => i !== idx))
}
const onCreate = async () => {
if (!canSubmit) {
return
}
setLoading(true)
setError(null)
try {
const payload: OrderCreate = {
orderRef: orderRef.trim(),
items: items.map((it) => ({
amazonSku: it.amazonSku.trim(),
quantity: Number(it.quantity),
title: it.title.trim() ? it.title.trim() : undefined
}))
}
const res = await api.post<{ id: string }>('/admin/orders', payload)
setCreatedId(res.data.id)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
return (
<div className="grid gap-4 max-w-[900px]">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/orders">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">Créer une commande</h1>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="orderRef">Référence commande</Label>
<Input
id="orderRef"
value={orderRef}
onChange={(e) => setOrderRef(e.target.value)}
placeholder="ex: AMAZON-ORDER-ID"
className="h-11"
/>
</div>
<div className="grid gap-3">
<h2 className="text-lg font-semibold">Articles</h2>
{items.map((it, idx) => (
<Card key={idx}>
<CardContent className="p-4 grid gap-3">
{/* Mobile: empilement vertical, Desktop: grille */}
<div className="grid grid-cols-1 sm:grid-cols-[1fr_80px_1fr] gap-3">
<div className="grid gap-1.5">
<Label>Amazon SKU</Label>
<Input
value={it.amazonSku}
onChange={(e) => setItem(idx, { amazonSku: e.target.value })}
className="h-11"
/>
</div>
<div className="grid gap-1.5">
<Label>Qté</Label>
<Input
value={it.quantity}
onChange={(e) => setItem(idx, { quantity: e.target.value })}
type="number"
min={1}
step={1}
className="h-11"
/>
</div>
<div className="grid gap-1.5">
<Label>Titre (optionnel)</Label>
<Input
value={it.title}
onChange={(e) => setItem(idx, { title: e.target.value })}
className="h-11"
/>
</div>
</div>
{items.length > 1 ? (
<Button
variant="ghost"
size="sm"
className="text-destructive justify-self-start"
onClick={() => removeItem(idx)}
disabled={loading}
>
<Trash2 className="h-4 w-4 mr-1" />
Supprimer
</Button>
) : null}
</CardContent>
</Card>
))}
<Button variant="outline" onClick={addItem} disabled={loading} className="h-11">
<Plus className="h-4 w-4 mr-1" />
Ajouter un article
</Button>
</div>
<Button
onClick={() => void onCreate()}
disabled={!canSubmit || loading}
className="h-11"
>
Créer la commande
</Button>
{createdId ? (
<Alert>
<Check className="h-4 w-4" />
<AlertDescription>
Commande créée.{' '}
<Link href={`/admin/orders/${createdId}`} className="font-medium underline">
Ouvrir la commande
</Link>
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
</div>
)
}

View File

@ -0,0 +1,920 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { ArrowLeft, RefreshCw, Plus, Trash2, ScanLine, PackageMinus, Truck, ExternalLink, Send, User } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { OrderDetailsData } from './types'
import { getFotaActivityStatusLabel, extractColissimoTracking } from './types'
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
new: { label: 'Nouvelle', variant: 'default' },
processing: { label: 'En cours', variant: 'secondary' },
ready: { label: 'Prête', variant: 'secondary' },
shipped: { label: 'Expédiée', variant: 'outline' },
done: { label: 'Terminée', variant: 'outline' },
}
export const OrderDetailsPage = ({ id }: { id: string }) => {
const [data, setData] = useState<OrderDetailsData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [scanError, setScanError] = useState<string | null>(null)
const [manualImei, setManualImei] = useState('')
const [fotaMoveLoading, setFotaMoveLoading] = useState(false)
const [fotaMoveError, setFotaMoveError] = useState<string | null>(null)
const [fotaMoveSuccess, setFotaMoveSuccess] = useState<{ backgroundActionId?: string | number } | null>(null)
const [destockLoading, setDestockLoading] = useState(false)
const [destockError, setDestockError] = useState<string | null>(null)
const [destockSuccess, setDestockSuccess] = useState(false)
const [colissimoLoading, setColissimoLoading] = useState(false)
const [colissimoError, setColissimoError] = useState<string | null>(null)
const [manualTracking, setManualTracking] = useState('')
const [amazonConfirmLoading, setAmazonConfirmLoading] = useState(false)
const [amazonConfirmError, setAmazonConfirmError] = useState<string | null>(null)
const [amazonConfirmSuccess, setAmazonConfirmSuccess] = useState(false)
const scannedImeis = useMemo(() => {
return new Set((data?.imeis ?? []).map((x) => x.imei))
}, [data?.imeis])
const totalExpected = useMemo(() => {
return (data?.items ?? []).reduce((sum, it) => sum + it.quantity, 0)
}, [data?.items])
const expectedModels = useMemo(() => {
return new Set((data?.expectedFotaModels ?? []).map((x) => x.trim()).filter(Boolean))
}, [data?.expectedFotaModels])
const mismatchedImeis = useMemo(() => {
if (!data) return []
if (expectedModels.size === 0) return []
return data.imeis.filter((it) => {
const model = (it.fotaModel ?? '').trim()
if (!model) return true
return !expectedModels.has(model)
})
}, [data, expectedModels])
const missingFotaInfoImeis = useMemo(() => {
if (!data) return []
return data.imeis.filter((it) => {
const model = (it.fotaModel ?? '').trim()
if (!model) return true
if (it.fotaLookupError) return true
return false
})
}, [data])
const canMarkReady = useMemo(() => {
if (!data) return false
if (totalExpected <= 0) return false
if (data.imeis.length !== totalExpected) return false
if (missingFotaInfoImeis.length > 0) return false
if (mismatchedImeis.length > 0) return false
return true
}, [data, mismatchedImeis.length, missingFotaInfoImeis.length, totalExpected])
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<OrderDetailsData>(`/admin/orders/${id}`)
setData(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
const submitImei = async (raw: string) => {
const imei = raw.trim()
if (!imei) {
return
}
setScanError(null)
if (scannedImeis.has(imei)) {
setScanError('IMEI déjà scanné')
return
}
try {
await api.post(`/admin/orders/${id}/scan-imei`, { imei })
setManualImei('')
await load()
} catch (err) {
setScanError(ensureErrorMessage(err))
}
}
const deleteImei = async (imeiId: string) => {
setScanError(null)
try {
await api.delete(`/admin/orders/${id}/imeis/${imeiId}`)
await load()
} catch (err) {
setScanError(ensureErrorMessage(err))
}
}
const submitTracking = async (raw: string) => {
const rawTrimmed = raw.trim()
if (!rawTrimmed) return
// Tenter d'extraire le numéro de suivi depuis un code-barres GeoLabel
const extracted = extractColissimoTracking(rawTrimmed)
const trackingNumber = extracted ?? rawTrimmed.replace(/\s/g, '')
setColissimoError(null)
setColissimoLoading(true)
try {
await api.post(`/admin/orders/${id}/tracking`, { trackingNumber })
setManualTracking('')
await load()
} catch (err) {
setColissimoError(ensureErrorMessage(err))
} finally {
setColissimoLoading(false)
}
}
const deleteTracking = async () => {
setColissimoError(null)
setColissimoLoading(true)
try {
await api.delete(`/admin/orders/${id}/tracking`)
await load()
} catch (err) {
const msg = ensureErrorMessage(err)
if (msg.includes('tracking_already_confirmed_on_amazon')) {
setColissimoError('Impossible de supprimer : le tracking a déjà été transmis à Amazon.')
} else {
setColissimoError(msg)
}
} finally {
setColissimoLoading(false)
}
}
const confirmAmazonShipment = async () => {
setAmazonConfirmError(null)
setAmazonConfirmSuccess(false)
setAmazonConfirmLoading(true)
try {
await api.post(`/admin/orders/${id}/amazon-confirm-shipment`)
setAmazonConfirmSuccess(true)
await load()
} catch (err) {
setAmazonConfirmError(ensureErrorMessage(err))
} finally {
setAmazonConfirmLoading(false)
}
}
const destockAxonaut = async () => {
setDestockError(null)
setDestockSuccess(false)
setDestockLoading(true)
try {
await api.post(`/admin/orders/${id}/axonaut-destock`)
setDestockSuccess(true)
await load()
} catch (err) {
setDestockError(ensureErrorMessage(err))
} finally {
setDestockLoading(false)
}
}
const moveFotaDevices = async () => {
setFotaMoveError(null)
setFotaMoveSuccess(null)
setFotaMoveLoading(true)
try {
const res = await api.post(`/admin/orders/${id}/fota-move`)
const backgroundActionId = (res.data as any)?.background_action_id ?? (res.data as any)?.backgroundActionId
setFotaMoveSuccess({ backgroundActionId })
await load()
} catch (err) {
setFotaMoveError(ensureErrorMessage(err))
} finally {
setFotaMoveLoading(false)
}
}
useEffect(() => {
void load()
}, [id])
if (loading && !data) {
return (
<div className="grid gap-4 max-w-[900px]">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</div>
)
}
return (
<div className="grid gap-4 max-w-[900px]">
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/admin/orders">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">Commande</h1>
</div>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{data ? (
<>
{/* Infos commande */}
<Card>
<CardContent className="p-4 flex flex-wrap gap-x-6 gap-y-2">
<div>
<p className="text-xs text-muted-foreground">Référence</p>
<p className="font-semibold">{data.orderRef}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Statut</p>
<Badge variant={statusConfig[data.status]?.variant ?? 'secondary'}>
{statusConfig[data.status]?.label ?? data.status}
</Badge>
</div>
<div>
<p className="text-xs text-muted-foreground">Progression</p>
<p className="font-semibold">{data.imeis.length} / {totalExpected} IMEI</p>
</div>
</CardContent>
</Card>
{expectedModels.size > 0 && mismatchedImeis.length > 0 ? (
<Alert variant="destructive">
<AlertDescription>
Modèle FOTA différent du modèle attendu.
<br />
Attendu: {(data.expectedFotaModels ?? []).join(', ')}
<br />
IMEI concernés: {mismatchedImeis.map((x) => x.imei).join(', ')}
</AlertDescription>
</Alert>
) : null}
{missingFotaInfoImeis.length > 0 ? (
<Alert variant="destructive">
<AlertDescription>
Infos FOTA manquantes sur certains appareils (modèle non trouvé ou erreur FOTA).
<br />
IMEI concernés: {missingFotaInfoImeis.map((x) => x.imei).join(', ')}
</AlertDescription>
</Alert>
) : null}
{/* Scanner */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ScanLine className="h-5 w-5" />
Scan IMEI / numéro de série
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<Button variant="default" className="h-12 text-base" asChild>
<Link href={`/admin/orders/${id}/scan`}>
<ScanLine className="h-5 w-5 mr-2" />
Scanner les IMEI
</Link>
</Button>
<Separator />
<div className="grid gap-2">
<p className="text-sm font-medium">Saisie manuelle</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={manualImei}
onChange={(e) => setManualImei(e.target.value)}
placeholder="IMEI ou numéro de série"
className="h-11 flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') {
void submitImei(manualImei)
}
}}
/>
<Button
onClick={() => void submitImei(manualImei)}
disabled={loading || !manualImei.trim()}
className="h-11"
>
<Plus className="h-4 w-4 mr-1" />
Ajouter
</Button>
</div>
</div>
{scanError ? (
<Alert variant="destructive">
<AlertDescription>{scanError}</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
{/* IMEI scannés */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-base">IMEI scannés ({data.imeis.length})</CardTitle>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={loading || fotaMoveLoading || !canMarkReady}
>
{fotaMoveLoading ? 'Préparation…' : 'Prêt à expédier'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Marquer la commande comme prête à expédier ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action vérifie la quantité et le modèle, puis transfère les appareils dans Teltonika.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void moveFotaDevices()}>
Confirmer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent>
{!canMarkReady ? (
<Alert>
<AlertDescription>
Pour passer en prêt à expédier, il faut : {data.imeis.length} / {totalExpected} scannés et aucun mismatch modèle.
</AlertDescription>
</Alert>
) : null}
{fotaMoveSuccess ? (
<Alert>
<AlertDescription>
Commande marquée prête à expédier. Transfert Teltonika en cours.
{typeof fotaMoveSuccess.backgroundActionId !== 'undefined' && fotaMoveSuccess.backgroundActionId !== null ? (
<>
<br />
Background action id: {String(fotaMoveSuccess.backgroundActionId)}
</>
) : null}
</AlertDescription>
</Alert>
) : null}
{fotaMoveError ? (
<Alert variant="destructive">
<AlertDescription>{fotaMoveError}</AlertDescription>
</Alert>
) : null}
{data.imeis.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">Aucun IMEI scanné.</p>
) : (
<>
{/* Mobile: cartes */}
<div className="grid gap-2 sm:hidden">
{data.imeis.map((it) => (
<div key={it.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="min-w-0">
<p className="font-mono text-sm font-medium">{it.imei}</p>
<p className="text-xs text-muted-foreground">
{new Date(it.createdAt).toLocaleString()}
</p>
{it.fotaLookupError ? (
<p className="text-xs text-destructive mt-1 truncate">FOTA: {it.fotaLookupError}</p>
) : (
<div className="text-xs text-muted-foreground mt-1 grid gap-0.5">
{it.fotaModel ? <p className="truncate">Modèle: {it.fotaModel}</p> : null}
{it.fotaCurrentFirmware ? <p className="truncate">FW: {it.fotaCurrentFirmware}</p> : null}
{getFotaActivityStatusLabel(it.fotaActivityStatus) ? (
<p className="truncate">Statut: {getFotaActivityStatusLabel(it.fotaActivityStatus)}</p>
) : null}
{it.fotaCompanyName ? <p className="truncate">Org: {it.fotaCompanyName}</p> : null}
{it.fotaGroupName ? <p className="truncate">Groupe: {it.fotaGroupName}</p> : null}
{it.fotaSeenAt ? <p className="truncate">Vu: {it.fotaSeenAt}</p> : null}
</div>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" disabled={loading}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cet IMEI ?</AlertDialogTitle>
<AlertDialogDescription>
L&apos;IMEI <strong>{it.imei}</strong> sera retiré de cette commande.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteImei(it.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>IMEI</TableHead>
<TableHead>Scanné le</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.imeis.map((it) => (
<TableRow key={it.id}>
<TableCell className="font-mono">
<div className="grid gap-1">
<span>{it.imei}</span>
{it.fotaLookupError ? (
<span className="text-xs text-destructive">FOTA: {it.fotaLookupError}</span>
) : (
<span className="text-xs text-muted-foreground">
{[
it.fotaModel ? `Modèle: ${it.fotaModel}` : null,
it.fotaCurrentFirmware ? `FW: ${it.fotaCurrentFirmware}` : null,
getFotaActivityStatusLabel(it.fotaActivityStatus)
? `Statut: ${getFotaActivityStatusLabel(it.fotaActivityStatus)}`
: null,
it.fotaCompanyName ? `Org: ${it.fotaCompanyName}` : null,
it.fotaGroupName ? `Groupe: ${it.fotaGroupName}` : null,
it.fotaSeenAt ? `Vu: ${it.fotaSeenAt}` : null
]
.filter(Boolean)
.join(' • ')}
</span>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(it.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" disabled={loading}>
<Trash2 className="h-4 w-4 text-destructive mr-1" />
Supprimer
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cet IMEI ?</AlertDialogTitle>
<AlertDialogDescription>
L&apos;IMEI <strong>{it.imei}</strong> sera retiré de cette commande.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteImei(it.id)}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
{/* Destockage Axonaut */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<PackageMinus className="h-4 w-4" />
Destockage Axonaut
</CardTitle>
{data.axonautDestockedAt ? (
<Badge variant="outline">
Destocké le {new Date(data.axonautDestockedAt).toLocaleString()}
</Badge>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={loading || destockLoading || !canMarkReady || !!data.axonautDestockedAt}
>
{destockLoading ? 'Destockage…' : 'Destocker Axonaut'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Destocker dans Axonaut ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action va diminuer le stock dans Axonaut pour chaque SKU de cette commande et créer une note avec les numéros IMEI.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void destockAxonaut()}>
Confirmer le destockage
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</CardHeader>
<CardContent>
{destockSuccess ? (
<Alert>
<AlertDescription>
Destockage Axonaut effectué. Stock mis à jour et note IMEI créée.
</AlertDescription>
</Alert>
) : null}
{destockError ? (
<Alert variant="destructive">
<AlertDescription>{destockError}</AlertDescription>
</Alert>
) : null}
{data.axonautDestockError && !destockError ? (
<Alert variant="destructive">
<AlertDescription>Dernière erreur : {data.axonautDestockError}</AlertDescription>
</Alert>
) : null}
{!data.axonautDestockedAt && !canMarkReady ? (
<p className="text-sm text-muted-foreground">
Tous les IMEI doivent être scannés et validés avant de pouvoir destocker.
</p>
) : null}
{data.axonautDestockedAt ? (
<p className="text-sm text-muted-foreground">
Le stock a é mis à jour dans Axonaut et une note avec les IMEI a é créée.
</p>
) : null}
</CardContent>
</Card>
{/* Adresse client */}
{data.shippingFetchedAt ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="h-4 w-4" />
Adresse de livraison
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 text-sm">
{data.shippingName ? (
<div className="sm:col-span-2">
<p className="text-xs text-muted-foreground">Nom</p>
<p className="font-medium">{data.shippingName}</p>
</div>
) : null}
{data.shippingLine1 ? (
<div className="sm:col-span-2">
<p className="text-xs text-muted-foreground">Adresse</p>
<p>{data.shippingLine1}</p>
{data.shippingLine2 ? <p>{data.shippingLine2}</p> : null}
{data.shippingLine3 ? <p>{data.shippingLine3}</p> : null}
</div>
) : null}
{data.shippingCity || data.shippingZipCode ? (
<div>
<p className="text-xs text-muted-foreground">Ville</p>
<p>{[data.shippingZipCode, data.shippingCity].filter(Boolean).join(' ')}</p>
</div>
) : null}
{data.shippingCountryCode ? (
<div>
<p className="text-xs text-muted-foreground">Pays</p>
<p>{data.shippingCountryCode}</p>
</div>
) : null}
{data.shippingPhone ? (
<div>
<p className="text-xs text-muted-foreground">Téléphone</p>
<p>{data.shippingPhone}</p>
</div>
) : null}
</div>
</CardContent>
</Card>
) : null}
{/* Expédition — Numéro de suivi */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="h-4 w-4" />
Expédition
</CardTitle>
{data.colissimoShippedAt ? (
<Badge variant="outline">
Expédié le {new Date(data.colissimoShippedAt).toLocaleString()}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-4">
{data.colissimoTrackingNumber ? (
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<p className="text-xs text-muted-foreground">N° suivi</p>
<p className="font-mono font-semibold">{data.colissimoTrackingNumber}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={`https://www.laposte.fr/outils/suivre-vos-envois?code=${data.colissimoTrackingNumber}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
Suivi La Poste
</a>
</Button>
{!data.amazonTrackingConfirmedAt ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={loading || colissimoLoading}>
<Trash2 className="h-4 w-4 mr-1 text-destructive" />
Supprimer
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer le numéro de suivi ?</AlertDialogTitle>
<AlertDialogDescription>
Le numéro <strong>{data.colissimoTrackingNumber}</strong> sera dissocié de cette commande. Vous pourrez en scanner un nouveau.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteTracking()}>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</div>
) : (
<>
<Button variant="default" className="h-12 text-base" asChild>
<Link href={`/admin/orders/${id}/scan-tracking`}>
<ScanLine className="h-5 w-5 mr-2" />
Scanner le bordereau
</Link>
</Button>
<Separator />
<div className="grid gap-2">
<p className="text-sm font-medium">Saisie manuelle</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={manualTracking}
onChange={(e) => setManualTracking(e.target.value)}
placeholder="Ex: 6A12345678901"
className="h-11 flex-1 font-mono"
onKeyDown={(e) => {
if (e.key === 'Enter') {
void submitTracking(manualTracking)
}
}}
/>
<Button
onClick={() => void submitTracking(manualTracking)}
disabled={loading || colissimoLoading || !manualTracking.trim()}
className="h-11"
>
<Truck className="h-4 w-4 mr-1" />
Associer
</Button>
</div>
</div>
</>
)}
{colissimoError ? (
<Alert variant="destructive">
<AlertDescription>{colissimoError}</AlertDescription>
</Alert>
) : null}
{data.colissimoError && !colissimoError ? (
<Alert variant="destructive">
<AlertDescription>Dernière erreur : {data.colissimoError}</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
{/* Confirmation Amazon */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Send className="h-4 w-4" />
Confirmation Amazon
</CardTitle>
{data.amazonTrackingConfirmedAt ? (
<Badge variant="outline">
Confirmé le {new Date(data.amazonTrackingConfirmedAt).toLocaleString()}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-4">
{data.amazonTrackingConfirmedAt ? (
<p className="text-sm text-muted-foreground">
Le numéro de suivi Colissimo a é transmis à Amazon. Le client a é notifié automatiquement.
</p>
) : !data.colissimoTrackingNumber ? (
<p className="text-sm text-muted-foreground">
Un numéro de suivi doit être associé avant de confirmer l&apos;expédition sur Amazon.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
Transmettre le numéro de suivi <span className="font-mono font-semibold">{data.colissimoTrackingNumber}</span> à Amazon pour notifier le client.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={loading || amazonConfirmLoading}
className="h-11 w-full sm:w-auto"
>
{amazonConfirmLoading ? (
<><RefreshCw className="h-4 w-4 mr-1 animate-spin" /> Confirmation</>
) : (
<><Send className="h-4 w-4 mr-1" /> Confirmer l&apos;expédition sur Amazon</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmer l&apos;expédition sur Amazon ?</AlertDialogTitle>
<AlertDialogDescription>
Le numéro de suivi Colissimo sera transmis à Amazon. Le client recevra une notification d&apos;expédition avec le lien de suivi.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={() => void confirmAmazonShipment()}>
Confirmer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{amazonConfirmSuccess && !data.amazonTrackingConfirmedAt ? (
<Alert>
<AlertDescription>
Expédition confirmée sur Amazon. Le client a é notifié.
</AlertDescription>
</Alert>
) : null}
{amazonConfirmError ? (
<Alert variant="destructive">
<AlertDescription>{amazonConfirmError}</AlertDescription>
</Alert>
) : null}
{data.amazonTrackingError && !amazonConfirmError ? (
<Alert variant="destructive">
<AlertDescription>Dernière erreur : {data.amazonTrackingError}</AlertDescription>
</Alert>
) : null}
</CardContent>
</Card>
{/* Articles */}
<Card>
<CardHeader>
<CardTitle className="text-base">Articles ({data.items.length})</CardTitle>
</CardHeader>
<CardContent>
{/* Mobile: cartes */}
<div className="grid gap-2 sm:hidden">
{data.items.map((it) => (
<div key={it.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium text-sm">{it.amazonSku}</p>
{it.title ? <p className="text-xs text-muted-foreground">{it.title}</p> : null}
</div>
<Badge variant="outline">x{it.quantity}</Badge>
</div>
))}
</div>
{/* Desktop: table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Amazon SKU</TableHead>
<TableHead>Qté</TableHead>
<TableHead>Titre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((it) => (
<TableRow key={it.id}>
<TableCell className="font-medium">{it.amazonSku}</TableCell>
<TableCell>{it.quantity}</TableCell>
<TableCell className="text-muted-foreground">{it.title ?? ''}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</>
) : null}
</div>
)
}

View File

@ -0,0 +1,168 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { RefreshCw, ChevronRight } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
type OrderListItem = {
id: string
orderRef: string
status: string
createdAt: string
updatedAt: string
}
const statusConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
new: { label: 'Nouvelle', variant: 'default' },
processing: { label: 'En cours', variant: 'secondary' },
ready: { label: 'Prête', variant: 'secondary' },
shipped: { label: 'Expédiée', variant: 'outline' },
done: { label: 'Terminée', variant: 'outline' },
}
const StatusBadge = ({ status }: { status: string }) => {
const config = statusConfig[status] ?? { label: status, variant: 'secondary' as const }
return <Badge variant={config.variant}>{config.label}</Badge>
}
export const OrdersListPage = () => {
const [items, setItems] = useState<OrderListItem[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await api.get<OrderListItem[]>('/admin/orders')
setItems(res.data)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
return (
<div className="grid gap-4 max-w-[1000px]">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-bold tracking-tight">Commandes</h1>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</Button>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{/* Mobile : cartes */}
<div className="grid gap-3 sm:hidden">
{loading && items.length === 0 ? (
<>
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</>
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">Aucune commande.</p>
) : (
items.map((it) => (
<Link key={it.id} href={`/admin/orders/${it.id}`}>
<Card className="active:bg-accent transition-colors">
<CardContent className="p-4 flex justify-between items-center">
<div>
<p className="font-medium">{it.orderRef}</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(it.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={it.status} />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
))
)}
</div>
{/* Desktop : table */}
<div className="hidden sm:block rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Référence</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Créée</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-5 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-8 w-16 ml-auto" /></TableCell>
</TableRow>
))}
</>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Aucune commande.
</TableCell>
</TableRow>
) : (
items.map((it) => (
<TableRow key={it.id}>
<TableCell className="font-medium">{it.orderRef}</TableCell>
<TableCell><StatusBadge status={it.status} /></TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(it.createdAt).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link href={`/admin/orders/${it.id}`}>
Ouvrir
<ChevronRight className="h-4 w-4 ml-1" />
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@ -0,0 +1,795 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Camera, CameraOff, SwitchCamera, RotateCw, Flashlight, FlashlightOff, Volume2, VolumeOff } from 'lucide-react'
type Props = {
onDecoded: (text: string) => void
disabled?: boolean
autoStart?: boolean
scanMode?: 'imei' | 'tracking' | 'mixed'
}
type CameraDevice = {
id: string
label?: string
}
const scoreBackCamera = (label: string) => {
const l = label.toLowerCase()
let score = 0
if (l.includes('back') || l.includes('rear') || l.includes('environment') || l.includes('arriere')) score += 100
if (l.includes('wide') || l.includes('1x') || l.includes('main')) score += 20
if (l.includes('ultra')) score -= 40
if (l.includes('tele')) score -= 15
if (l.includes('front') || l.includes('user') || l.includes('facetime')) score -= 120
return score
}
const isLikelyBackCamera = (label: string) => scoreBackCamera(label) >= 80
const playBeep = () => {
try {
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = 880
gain.gain.value = 0.3
osc.start()
osc.stop(ctx.currentTime + 0.12)
osc.onended = () => ctx.close()
} catch {
// Audio non supporte
}
}
const iconBtnStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 10,
border: '1px solid #e0e0e0',
background: '#fff',
cursor: 'pointer',
transition: 'background 0.15s',
}
const iconBtnActiveStyle: React.CSSProperties = {
...iconBtnStyle,
background: '#18181b',
borderColor: '#18181b',
color: '#fff',
}
const iconBtnDisabledStyle: React.CSSProperties = {
...iconBtnStyle,
opacity: 0.35,
cursor: 'not-allowed',
}
export const QrImeiScanner = ({ onDecoded, disabled, autoStart, scanMode = 'mixed' }: Props) => {
const isIOS = useMemo(() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const isIPhoneOrIPad = /iPhone|iPad|iPod/i.test(ua)
const isIPadDesktopUA = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
return isIPhoneOrIPad || isIPadDesktopUA
}, [])
const [active, setActive] = useState(false)
const [error, setError] = useState<string | null>(null)
const [infoMessage, setInfoMessage] = useState<string | null>(null)
const [cameras, setCameras] = useState<CameraDevice[]>([])
const [cameraIndex, setCameraIndex] = useState(0)
const [flip, setFlip] = useState(false)
const [torchOn, setTorchOn] = useState(false)
const [torchSupported, setTorchSupported] = useState(false)
const [beepEnabled, setBeepEnabled] = useState(true)
const [debugInfo, setDebugInfo] = useState({
videoWidth: 0,
videoHeight: 0,
frames: 0,
decodingActive: false,
})
const beepEnabledRef = useRef(true)
beepEnabledRef.current = beepEnabled
const lastDecodedRef = useRef<string | null>(null)
const onDecodedRef = useRef(onDecoded)
onDecodedRef.current = onDecoded
const autoStartedRef = useRef(false)
const userSelectedCameraRef = useRef(false)
const frameCounterRef = useRef(0)
const decodeCountRef = useRef(0)
const engineToggleDoneRef = useRef(false)
const engineToggleTimeoutRef = useRef<number | null>(null)
const aliveLogIntervalRef = useRef<number | null>(null)
const debugUiIntervalRef = useRef<number | null>(null)
useEffect(() => {
if (autoStart && !autoStartedRef.current) {
autoStartedRef.current = true
if (isIOS) {
setInfoMessage('iPhone: appuyez sur le bouton caméra pour démarrer')
return
}
setActive(true)
}
}, [autoStart, isIOS])
const regionId = useMemo(() => {
return `qr-reader-${Math.random().toString(16).slice(2)}`
}, [])
useEffect(() => {
if (!active) {
lastDecodedRef.current = null
frameCounterRef.current = 0
decodeCountRef.current = 0
engineToggleDoneRef.current = false
if (engineToggleTimeoutRef.current) {
window.clearTimeout(engineToggleTimeoutRef.current)
engineToggleTimeoutRef.current = null
}
if (aliveLogIntervalRef.current) {
window.clearInterval(aliveLogIntervalRef.current)
aliveLogIntervalRef.current = null
}
if (debugUiIntervalRef.current) {
window.clearInterval(debugUiIntervalRef.current)
debugUiIntervalRef.current = null
}
setDebugInfo((prev) => ({ ...prev, decodingActive: false }))
return
}
let cancelled = false
let qr: any
const start = async () => {
setError(null)
setInfoMessage(null)
try {
const mod = await import('html5-qrcode')
const Html5Qrcode = mod.Html5Qrcode
const Html5QrcodeSupportedFormats = mod.Html5QrcodeSupportedFormats
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''
const isAppleMobile = ua.includes('iPhone') || ua.includes('iPad')
const hasBarcodeDetector = typeof window !== 'undefined' && 'BarcodeDetector' in window
if (cancelled) return
decodeCountRef.current = 0
engineToggleDoneRef.current = false
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === '[::1]'
if (!window.isSecureContext && !isLocalhost) {
setError('Camera requires HTTPS on iPhone/mobile browsers')
setActive(false)
return
}
const all = (await Html5Qrcode.getCameras()) as CameraDevice[]
if (!cancelled) {
setCameras(all ?? [])
}
// Premiere ouverture: preferer une camera arriere.
if (!cancelled && all?.length && cameraIndex === 0 && !userSelectedCameraRef.current) {
let bestIdx = 0
let bestScore = Number.NEGATIVE_INFINITY
all.forEach((c, idx) => {
const score = scoreBackCamera(c.label ?? '')
if (score > bestScore) {
bestScore = score
bestIdx = idx
}
})
if (bestIdx > 0) {
setCameraIndex(bestIdx)
return
}
}
const onScan = (decodedText: string) => {
decodeCountRef.current += 1
const cleaned = decodedText.trim()
if (!cleaned) return
if (lastDecodedRef.current === cleaned) return
lastDecodedRef.current = cleaned
if (beepEnabledRef.current) playBeep()
onDecodedRef.current(cleaned)
setTimeout(() => {
if (lastDecodedRef.current === cleaned) {
lastDecodedRef.current = null
}
}, 2000)
}
const adaptiveQrbox = (viewfinderWidth: number, viewfinderHeight: number) => {
if (scanMode === 'imei') {
const scale = isAppleMobile ? 0.75 : 0.45
const size = Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * scale)
const clamped = Math.max(220, Math.min(size, 320))
return { width: clamped, height: clamped }
}
if (scanMode === 'tracking') {
const w = Math.floor(viewfinderWidth * 0.9)
const h = Math.floor(viewfinderHeight * 0.3)
return { width: Math.max(w, 260), height: Math.max(h, 90) }
}
const w = Math.floor(viewfinderWidth * 0.9)
const h = Math.floor(viewfinderHeight * 0.35)
return { width: Math.max(w, 250), height: Math.max(h, 80) }
}
const getFormatsToSupport = () => {
if (!Html5QrcodeSupportedFormats) return undefined
if (scanMode === 'imei') {
return [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.DATA_MATRIX,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.EAN_13,
]
}
if (scanMode === 'tracking') {
return [
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.ITF,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.CODABAR,
Html5QrcodeSupportedFormats.QR_CODE,
]
}
return [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.DATA_MATRIX,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.ITF,
Html5QrcodeSupportedFormats.CODABAR,
Html5QrcodeSupportedFormats.PDF_417,
]
}
const qrbox = adaptiveQrbox
const formatsToSupport = getFormatsToSupport()
const defaultFps = scanMode === 'imei' ? (isAppleMobile ? 5 : 10) : 10
const buildConfig = (useBarCodeDetectorIfSupported: boolean, fps: number) => ({
fps,
qrbox,
experimentalFeatures: { useBarCodeDetectorIfSupported },
formatsToSupport,
disableFlip: scanMode === 'imei'
}) as any
let currentUseBarcodeDetector = hasBarcodeDetector && !isAppleMobile
let config: any = buildConfig(currentUseBarcodeDetector, defaultFps)
let safeConfig: any = buildConfig(currentUseBarcodeDetector, 8)
const setEngineConfig = (useBarcodeDetector: boolean) => {
currentUseBarcodeDetector = useBarcodeDetector
config = buildConfig(currentUseBarcodeDetector, defaultFps)
safeConfig = buildConfig(currentUseBarcodeDetector, 8)
}
qr = new Html5Qrcode(regionId)
const selected = all?.length ? all[Math.min(cameraIndex, all.length - 1)] : null
const getIosVideoConstraints = () => {
if (!isAppleMobile) return undefined
if (selected?.id) {
return {
deviceId: { exact: selected.id },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 15, max: 20 },
} as MediaTrackConstraints
}
return {
facingMode: { exact: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 15, max: 20 },
} as MediaTrackConstraints
}
if (isAppleMobile) {
const iosConstraints = getIosVideoConstraints()
if (iosConstraints) {
config = { ...config, videoConstraints: iosConstraints }
safeConfig = { ...safeConfig, videoConstraints: iosConstraints }
}
}
const isConstraintError = (msg: string) => {
return msg.includes('Invalid constraint') || msg.includes('OverconstrainedError')
}
const startOnBackCameraFirst = async () => {
if (userSelectedCameraRef.current) return false
const preferredCamera = all
?.map((c, idx) => ({ c, idx, score: scoreBackCamera(c.label ?? '') }))
.sort((a, b) => b.score - a.score)?.[0]
if (preferredCamera?.c?.id && isLikelyBackCamera(preferredCamera.c.label ?? '')) {
try {
await qr.start(
preferredCamera.c.id,
config,
onScan,
() => {
frameCounterRef.current += 1
}
)
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isConstraintError(msg)) throw err
}
}
try {
await qr.start(
{ facingMode: 'environment' },
config,
onScan,
() => {
frameCounterRef.current += 1
}
)
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isConstraintError(msg)) throw err
}
return false
}
const startWithCurrentConfig = async () => {
try {
if (isAppleMobile) {
try {
await qr.start(
selected?.id ?? { facingMode: 'environment' },
config,
onScan,
() => {
frameCounterRef.current += 1
}
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isConstraintError(msg)) throw err
try {
await qr.start(
selected?.id ?? { facingMode: 'environment' },
safeConfig,
onScan,
() => {
frameCounterRef.current += 1
}
)
} catch {
await qr.start(
{},
safeConfig,
onScan,
() => {
frameCounterRef.current += 1
}
)
}
}
} else {
const startedWithBackConstraint = await startOnBackCameraFirst()
if (startedWithBackConstraint) {
// nothing else
} else if (selected?.id) {
await qr.start(
selected.id,
config,
onScan,
() => {
frameCounterRef.current += 1
}
)
} else {
await qr.start(
{ facingMode: 'environment' },
config,
onScan,
() => {
frameCounterRef.current += 1
}
)
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isConstraintError(msg)) throw err
await qr.start(
{},
safeConfig,
onScan,
() => {
frameCounterRef.current += 1
}
)
}
}
await startWithCurrentConfig()
const applyPreviewStyles = async () => {
const rootEl = document.getElementById(regionId)
const scannerChild = rootEl?.firstElementChild as HTMLElement | null
if (scannerChild) {
scannerChild.style.height = '100%'
scannerChild.style.minHeight = '100%'
}
const videoEl = rootEl?.querySelector('video') as HTMLVideoElement | null
if (!videoEl) return
videoEl.style.width = '100%'
videoEl.style.height = '100%'
videoEl.style.objectFit = isAppleMobile ? 'cover' : 'cover'
videoEl.style.display = 'block'
videoEl.setAttribute('playsinline', 'true')
videoEl.setAttribute('webkit-playsinline', 'true')
videoEl.muted = true
const shouldDisableTransform = isAppleMobile || scanMode === 'imei'
videoEl.style.transform = shouldDisableTransform ? '' : ''
videoEl.style.transformOrigin = 'center center'
const stream = videoEl.srcObject as MediaStream | null
const track = stream?.getVideoTracks?.()?.[0]
if (!track) return
const settings = track.getSettings?.()
const settingsWidth = typeof settings?.width === 'number' ? settings.width : undefined
const settingsHeight = typeof settings?.height === 'number' ? settings.height : undefined
if (!cancelled) {
setDebugInfo((prev) => ({
...prev,
videoWidth: settingsWidth ?? videoEl.videoWidth ?? 0,
videoHeight: settingsHeight ?? videoEl.videoHeight ?? 0,
}))
}
{
// Demander plus de pixels quand possible (iOS ignore souvent, mais on tente).
void qr
.applyVideoConstraints({
width: { ideal: isAppleMobile ? 1920 : 1920 },
height: { ideal: isAppleMobile ? 1080 : 1080 },
frameRate: { ideal: isAppleMobile ? 15 : 12, max: isAppleMobile ? 20 : 15 },
})
.catch(() => {})
const caps = track.getCapabilities?.() as
| {
focusMode?: string[] | string
zoom?: { min?: number; max?: number }
}
| undefined
const focusModes = Array.isArray(caps?.focusMode)
? caps.focusMode
: typeof caps?.focusMode === 'string'
? [caps.focusMode]
: []
if (focusModes.includes('continuous')) {
void track
.applyConstraints({
advanced: [{ focusMode: 'continuous' } as unknown as MediaTrackConstraintSet]
})
.catch(() => {})
}
if (!isAppleMobile) {
const minZoom = typeof caps?.zoom?.min === 'number' ? caps.zoom.min : 1
const maxZoom = typeof caps?.zoom?.max === 'number' ? caps.zoom.max : 1
if (maxZoom > minZoom) {
const targetZoom = Math.min(Math.max(2, minZoom), maxZoom)
void track
.applyConstraints({
advanced: [{ zoom: targetZoom } as unknown as MediaTrackConstraintSet]
})
.catch(() => {})
}
}
}
}
const detectTorch = () => {
const videoEl = document.getElementById(regionId)?.querySelector('video') as HTMLVideoElement | null
if (!videoEl) return
const stream = videoEl.srcObject as MediaStream | null
const track = stream?.getVideoTracks?.()?.[0]
if (!track) return
const caps = track.getCapabilities?.() as any
if (caps?.torch && !cancelled) {
setTorchSupported(true)
}
}
const scheduleIosEngineToggle = () => {
if (!isAppleMobile || engineToggleDoneRef.current) return
if (engineToggleTimeoutRef.current) {
window.clearTimeout(engineToggleTimeoutRef.current)
}
const framesAtStart = frameCounterRef.current
const decodeAtStart = decodeCountRef.current
engineToggleTimeoutRef.current = window.setTimeout(() => {
engineToggleTimeoutRef.current = null
void (async () => {
if (cancelled || engineToggleDoneRef.current) return
if (decodeCountRef.current > decodeAtStart) return
if (frameCounterRef.current <= framesAtStart) return
engineToggleDoneRef.current = true
try {
await qr.stop()
await qr.clear()
} catch {
// ignore
}
if (cancelled) return
setEngineConfig(!currentUseBarcodeDetector)
qr = new Html5Qrcode(regionId)
try {
await startWithCurrentConfig()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
return
}
await applyPreviewStyles()
detectTorch()
})()
}, 1800)
}
void applyPreviewStyles()
detectTorch()
setTimeout(() => {
void applyPreviewStyles()
detectTorch()
}, 300)
setTimeout(() => {
void applyPreviewStyles()
detectTorch()
}, 900)
scheduleIosEngineToggle()
if (isAppleMobile) {
// Si iOS reste en 480x640, on relance avec contraintes plus strictes.
setTimeout(() => {
const videoEl = document.getElementById(regionId)?.querySelector('video') as HTMLVideoElement | null
const width = videoEl?.videoWidth ?? 0
if (cancelled || width >= 720) return
void (async () => {
try {
await qr.stop()
await qr.clear()
} catch {
// ignore
}
if (cancelled) return
qr = new Html5Qrcode(regionId)
try {
await qr.start(
selected?.id ?? { facingMode: 'environment' },
safeConfig,
onScan,
() => {
frameCounterRef.current += 1
}
)
} catch {
// ignore
}
})()
}, 1200)
}
setDebugInfo((prev) => ({ ...prev, decodingActive: true }))
aliveLogIntervalRef.current = window.setInterval(() => {
// Debug temporaire demandé
console.log('scanner alive')
}, 5000)
debugUiIntervalRef.current = window.setInterval(() => {
const videoEl = document.getElementById(regionId)?.querySelector('video') as HTMLVideoElement | null
const stream = videoEl?.srcObject as MediaStream | null
const track = stream?.getVideoTracks?.()?.[0]
const settings = track?.getSettings?.()
const settingsWidth = typeof settings?.width === 'number' ? settings.width : undefined
const settingsHeight = typeof settings?.height === 'number' ? settings.height : undefined
setDebugInfo({
videoWidth: settingsWidth ?? videoEl?.videoWidth ?? 0,
videoHeight: settingsHeight ?? videoEl?.videoHeight ?? 0,
frames: frameCounterRef.current,
decodingActive: true,
})
}, 500)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
}
}
void start()
return () => {
cancelled = true
setTorchOn(false)
setTorchSupported(false)
setDebugInfo((prev) => ({ ...prev, decodingActive: false }))
if (engineToggleTimeoutRef.current) {
window.clearTimeout(engineToggleTimeoutRef.current)
engineToggleTimeoutRef.current = null
}
if (aliveLogIntervalRef.current) {
window.clearInterval(aliveLogIntervalRef.current)
aliveLogIntervalRef.current = null
}
if (debugUiIntervalRef.current) {
window.clearInterval(debugUiIntervalRef.current)
debugUiIntervalRef.current = null
}
const stop = async () => {
try {
if (qr) {
await qr.stop()
await qr.clear()
}
} catch {
// ignore
}
}
void stop()
}
}, [active, cameraIndex, regionId, scanMode])
useEffect(() => {
const videoEl = document.getElementById(regionId)?.querySelector('video') as HTMLVideoElement | null
if (!videoEl) return
const shouldDisableTransform = isIOS || scanMode === 'imei'
videoEl.style.transform = shouldDisableTransform ? '' : flip ? 'rotate(180deg)' : ''
videoEl.style.transformOrigin = 'center center'
}, [flip, isIOS, regionId, scanMode])
useEffect(() => {
if (!active) return
const videoEl = document.getElementById(regionId)?.querySelector('video') as HTMLVideoElement | null
if (!videoEl) return
const stream = videoEl.srcObject as MediaStream | null
const track = stream?.getVideoTracks?.()?.[0]
if (!track) return
try {
void track.applyConstraints({ advanced: [{ torch: torchOn } as any] })
} catch {
// ignore
}
}, [torchOn, active, regionId])
const cycleCamera = () => {
if (cameras.length <= 1) return
userSelectedCameraRef.current = true
setCameraIndex((i) => (i + 1) % cameras.length)
}
const toggleActive = () => {
setInfoMessage(null)
setActive((v) => !v)
}
return (
<div
style={{
display: 'grid',
gap: 8,
minHeight: 0,
height: '100%',
gridTemplateRows: 'auto minmax(0, 1fr)',
}}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<button
onClick={toggleActive}
disabled={disabled}
style={disabled ? iconBtnDisabledStyle : active ? iconBtnActiveStyle : iconBtnStyle}
title={active ? 'Arreter la camera' : 'Demarrer la camera'}
>
{active ? <CameraOff size={20} /> : <Camera size={20} />}
</button>
<button
onClick={cycleCamera}
disabled={disabled || !active || cameras.length <= 1}
style={disabled || !active || cameras.length <= 1 ? iconBtnDisabledStyle : iconBtnStyle}
title="Changer de camera"
>
<SwitchCamera size={20} />
</button>
<button
onClick={() => setFlip((v) => !v)}
disabled={disabled || !active}
style={disabled || !active ? iconBtnDisabledStyle : flip ? iconBtnActiveStyle : iconBtnStyle}
title="Retourner l'image"
>
<RotateCw size={20} />
</button>
<button
onClick={() => setTorchOn((v) => !v)}
disabled={disabled || !active || !torchSupported}
style={disabled || !active || !torchSupported ? iconBtnDisabledStyle : torchOn ? iconBtnActiveStyle : iconBtnStyle}
title={torchOn ? 'Eteindre la lampe' : 'Allumer la lampe'}
>
{torchOn ? <FlashlightOff size={20} /> : <Flashlight size={20} />}
</button>
<button
onClick={() => setBeepEnabled((v) => !v)}
disabled={disabled}
style={disabled ? iconBtnDisabledStyle : beepEnabled ? iconBtnActiveStyle : iconBtnStyle}
title={beepEnabled ? 'Desactiver le son' : 'Activer le son'}
>
{beepEnabled ? <Volume2 size={20} /> : <VolumeOff size={20} />}
</button>
</div>
{error ? <div style={{ color: '#b00020', fontSize: 13 }}>Erreur camera: {error}</div> : null}
{infoMessage ? <div style={{ color: '#666', fontSize: 13 }}>{infoMessage}</div> : null}
<div style={{ color: '#666', fontSize: 12 }}>
debug video: {debugInfo.videoWidth}x{debugInfo.videoHeight}
</div>
<div style={{ color: '#666', fontSize: 12 }}>
debug frames: {debugInfo.frames} | decoding: {debugInfo.decodingActive ? 'oui' : 'non'}
</div>
<div
id={regionId}
style={{
width: '100%',
height: '100%',
background: '#111',
border: '1px solid #ddd',
borderRadius: 8,
overflow: 'hidden'
}}
/>
</div>
)
}

View File

@ -0,0 +1,259 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, Check, Loader2 } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import { QrImeiScanner } from './QrImeiScanner'
import { ImeiValidationModal } from './ImeiValidationModal'
import type { OrderDetailsData, FotaLookupResult } from './types'
type Props = { id: string }
export function ScanPage({ id }: Props) {
const router = useRouter()
const [data, setData] = useState<OrderDetailsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lookupPending, setLookupPending] = useState(false)
const [lookupResult, setLookupResult] = useState<FotaLookupResult | null>(null)
const [lookupError, setLookupError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const busyRef = useRef(false)
const load = useCallback(async () => {
try {
setError(null)
const res = await api.get(`/admin/orders/${id}`)
setData(res.data as OrderDetailsData)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const scannedImeis = useMemo(() => {
return new Set((data?.imeis ?? []).map((x) => x.imei))
}, [data?.imeis])
const totalExpected = useMemo(() => {
return (data?.items ?? []).reduce((sum, it) => sum + it.quantity, 0)
}, [data?.items])
const expectedModels = useMemo(() => {
return new Set(
(data?.expectedFotaModels ?? []).map((x) => x.trim()).filter(Boolean)
)
}, [data?.expectedFotaModels])
const handleDecoded = useCallback(
async (text: string) => {
if (busyRef.current || modalOpen) return
const cleaned = text.trim()
if (!cleaned) return
if (scannedImeis.has(cleaned)) {
setLookupError('IMEI deja scanne sur cette commande')
setTimeout(() => setLookupError(null), 3000)
return
}
busyRef.current = true
setLookupPending(true)
setLookupError(null)
setSaveError(null)
try {
const res = await api.get('/admin/fota/lookup', {
params: { identifier: cleaned },
})
const result = res.data as FotaLookupResult
if (scannedImeis.has(result.imei)) {
setLookupError('IMEI deja scanne sur cette commande')
setTimeout(() => setLookupError(null), 3000)
return
}
setLookupResult(result)
setModalOpen(true)
} catch (err) {
setLookupError(ensureErrorMessage(err))
setTimeout(() => setLookupError(null), 5000)
} finally {
setLookupPending(false)
busyRef.current = false
}
},
[modalOpen, scannedImeis]
)
const handleConfirm = useCallback(async () => {
if (!lookupResult) return
setSaving(true)
setSaveError(null)
try {
await api.post(`/admin/orders/${id}/scan-imei`, {
imei: lookupResult.imei,
})
setModalOpen(false)
setLookupResult(null)
const res = await api.get(`/admin/orders/${id}`)
const updated = res.data as OrderDetailsData
setData(updated)
const newCount = updated.imeis.length
const expected = updated.items.reduce((sum, it) => sum + it.quantity, 0)
if (newCount >= expected) {
router.push(`/admin/orders/${id}`)
}
} catch (err) {
setSaveError(ensureErrorMessage(err))
} finally {
setSaving(false)
}
}, [id, lookupResult, router])
const handleDismiss = useCallback(() => {
setModalOpen(false)
setLookupResult(null)
setSaveError(null)
}, [])
if (loading) {
return (
<div className="p-4 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
if (error || !data) {
return (
<div className="p-4 space-y-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/orders/${id}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<Alert variant="destructive">
<AlertDescription>{error ?? 'Commande introuvable'}</AlertDescription>
</Alert>
</div>
)
}
const scannedCount = data.imeis.length
return (
<div className="flex flex-col" style={{ height: 'calc(100dvh - 32px)' }}>
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-3 flex-shrink-0">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/orders/${id}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-lg font-bold leading-tight">Scan IMEI</h1>
<p className="text-xs text-muted-foreground">{data.orderRef}</p>
</div>
</div>
<Badge variant={scannedCount >= totalExpected ? 'default' : 'secondary'} className="text-sm">
{scannedCount} / {totalExpected} IMEI
</Badge>
</div>
<div
className="grid flex-1 min-h-0 gap-3"
style={{ gridTemplateRows: 'minmax(0, 1fr) minmax(0, 1fr)' }}
>
{/* Camera */}
<div className="min-h-0 relative">
<QrImeiScanner
onDecoded={(text) => void handleDecoded(text)}
disabled={lookupPending || saving}
autoStart
scanMode="imei"
/>
{lookupPending && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 8 }}
>
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
)}
</div>
{/* Liste des IMEI scannes */}
<div className="min-h-0 border rounded-lg overflow-hidden flex flex-col bg-background">
{lookupError && (
<Alert variant="destructive" className="m-2 flex-shrink-0">
<AlertDescription>{lookupError}</AlertDescription>
</Alert>
)}
<div className="flex-1 overflow-y-auto">
{scannedCount === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Aucun IMEI scanne
</p>
) : (
data.imeis.map((imei) => (
<div
key={imei.id}
className="flex items-center justify-between px-3 py-2 border-b last:border-b-0"
>
<div className="min-w-0">
<p className="font-mono text-sm truncate">{imei.imei}</p>
<p className="text-xs text-muted-foreground truncate">
{[imei.fotaModel, imei.fotaSerial].filter(Boolean).join(' - ')}
</p>
</div>
<Check className="h-4 w-4 text-green-600 flex-shrink-0 ml-2" />
</div>
))
)}
</div>
</div>
</div>
{/* Modale de validation */}
<ImeiValidationModal
open={modalOpen}
onOpenChange={setModalOpen}
lookupResult={lookupResult}
saving={saving}
saveError={saveError}
expectedFotaModels={expectedModels}
onConfirm={() => void handleConfirm()}
onDismiss={handleDismiss}
/>
</div>
)
}

View File

@ -0,0 +1,245 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { ArrowLeft, Loader2, Package } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { QrImeiScanner } from './QrImeiScanner'
import type { OrderDetailsData } from './types'
import { extractColissimoTracking } from './types'
type Props = { id: string }
export function ScanTrackingPage({ id }: Props) {
const router = useRouter()
const [data, setData] = useState<OrderDetailsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [scannedValue, setScannedValue] = useState<string | null>(null)
const [trackingNumber, setTrackingNumber] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [scanError, setScanError] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setError(null)
const res = await api.get(`/admin/orders/${id}`)
setData(res.data as OrderDetailsData)
} catch (err) {
setError(ensureErrorMessage(err))
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
void load()
}, [load])
const handleDecoded = useCallback(
(text: string) => {
if (modalOpen || saving) return
const raw = text.trim()
if (!raw) return
const extracted = extractColissimoTracking(raw)
const tracking = extracted ?? raw.replace(/\s/g, '')
if (!tracking) {
setScanError('Code-barres non reconnu')
setTimeout(() => setScanError(null), 3000)
return
}
setScanError(null)
setSaveError(null)
setScannedValue(raw)
setTrackingNumber(tracking)
setModalOpen(true)
},
[modalOpen, saving]
)
const handleConfirm = useCallback(async () => {
if (!trackingNumber) return
setSaving(true)
setSaveError(null)
try {
await api.post(`/admin/orders/${id}/tracking`, { trackingNumber })
setModalOpen(false)
setTrackingNumber(null)
setScannedValue(null)
router.push(`/admin/orders/${id}`)
} catch (err) {
setSaveError(ensureErrorMessage(err))
} finally {
setSaving(false)
}
}, [id, trackingNumber, router])
const handleDismiss = useCallback(() => {
setModalOpen(false)
setTrackingNumber(null)
setScannedValue(null)
setSaveError(null)
}, [])
if (loading) {
return (
<div className="p-4 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (error || !data) {
return (
<div className="p-4 space-y-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/orders/${id}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<Alert variant="destructive">
<AlertDescription>{error ?? 'Commande introuvable'}</AlertDescription>
</Alert>
</div>
)
}
const hasTracking = !!data.colissimoTrackingNumber
return (
<div className="flex flex-col" style={{ height: 'calc(100dvh - 32px)' }}>
{/* Header */}
<div className="flex items-center justify-between gap-2 mb-3 flex-shrink-0">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/orders/${id}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-lg font-bold leading-tight">Scan Colissimo</h1>
<p className="text-xs text-muted-foreground">{data.orderRef}</p>
</div>
</div>
{hasTracking ? (
<Badge variant="default" className="text-sm">
<Package className="h-3 w-3 mr-1" />
{data.colissimoTrackingNumber}
</Badge>
) : (
<Badge variant="secondary" className="text-sm">
Pas de suivi
</Badge>
)}
</div>
{/* Camera */}
<div className="flex-1 min-h-0 relative">
<QrImeiScanner
onDecoded={handleDecoded}
disabled={saving || hasTracking}
autoStart
scanMode="tracking"
/>
{saving && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 8 }}
>
<Loader2 className="h-8 w-8 animate-spin text-white" />
</div>
)}
</div>
{/* Erreur scan inline */}
{scanError && (
<Alert variant="destructive" className="mt-2 flex-shrink-0">
<AlertDescription>{scanError}</AlertDescription>
</Alert>
)}
{/* Info si tracking deja associe */}
{hasTracking && (
<Alert className="mt-2 flex-shrink-0">
<AlertDescription>
Un numéro de suivi est déjà associé à cette commande : <span className="font-mono font-semibold">{data.colissimoTrackingNumber}</span>
</AlertDescription>
</Alert>
)}
{/* Modale de confirmation */}
<AlertDialog open={modalOpen} onOpenChange={setModalOpen}>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>Numéro de suivi détecté</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
Confirmez le numéro de suivi scanné
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">N° suivi</span>
<span className="font-mono font-semibold">{trackingNumber}</span>
</div>
{scannedValue && scannedValue !== trackingNumber && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Code brut</span>
<span className="font-mono text-xs text-right max-w-[60%] truncate">{scannedValue}</span>
</div>
)}
</div>
{saveError && (
<Alert variant="destructive" className="mt-2">
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={handleDismiss} disabled={saving}>
Recommencer
</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleConfirm()} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validation...
</>
) : (
'Valider'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -0,0 +1,108 @@
export type OrderItem = {
id: string
amazonSku: string
quantity: number
title: string | null
expectedFotaModel?: string | null
}
export type OrderImei = {
id: string
imei: string
createdAt: string
fotaModel?: string | null
fotaSerial?: string | null
fotaCurrentFirmware?: string | null
fotaActivityStatus?: number | null
fotaSeenAt?: string | null
fotaCompanyName?: string | null
fotaGroupName?: string | null
fotaLookupError?: string | null
fotaLastLookupAt?: string | null
fotaMovedAt?: string | null
fotaMoveError?: string | null
}
export type OrderDetailsData = {
id: string
orderRef: string
status: string
createdAt: string
updatedAt: string
axonautDestockedAt?: string | null
axonautDestockError?: string | null
colissimoTrackingNumber?: string | null
colissimoLabelUrl?: string | null
colissimoShippedAt?: string | null
colissimoError?: string | null
amazonTrackingConfirmedAt?: string | null
amazonTrackingError?: string | null
shippingName?: string | null
shippingFirstName?: string | null
shippingLastName?: string | null
shippingLine1?: string | null
shippingLine2?: string | null
shippingLine3?: string | null
shippingCity?: string | null
shippingZipCode?: string | null
shippingCountryCode?: string | null
shippingPhone?: string | null
shippingFetchedAt?: string | null
shippingFetchError?: string | null
items: OrderItem[]
expectedFotaModels?: string[]
imeis: OrderImei[]
}
export type FotaLookupResult = {
imei: string
fotaModel: string | null
fotaSerial: string | null
fotaCurrentFirmware: string | null
fotaActivityStatus: number | null
fotaSeenAt: string | null
fotaCompanyName: string | null
fotaGroupName: string | null
}
/**
* Extrait le numéro de suivi Colissimo 13 caractères depuis un code-barres GeoLabel La Poste.
* Format GS1-128 : FNC1 (%) + 7 CP dest + 2 routage + 2 code produit + 10 identifiant + 3 service + 3 pays
* Retourne null si le format ne correspond pas.
*/
export const extractColissimoTracking = (barcodeScan: string): string | null => {
const data = barcodeScan.replace(/^(%|\]C1|\x1d)/, '')
if (data.length < 21) return null
const productCode = data.substring(9, 11)
const parcelId = data.substring(11, 21)
if (!/^[0-9A-Z]{2}$/.test(productCode)) return null
if (!/^\d{10}$/.test(parcelId)) return null
const reversed = parcelId.split('').reverse()
let sumOdd = 0
let sumEven = 0
for (let i = 0; i < reversed.length; i++) {
const d = parseInt(reversed[i], 10)
if (i % 2 === 0) sumOdd += d
else sumEven += d
}
const total = sumOdd * 3 + sumEven
const checkResult = (Math.floor(total / 10) + 1) * 10 - total
const checkDigit = checkResult === 10 ? 0 : checkResult
return productCode + parcelId + checkDigit
}
export const getFotaActivityStatusLabel = (status?: number | null): string | null => {
if (status === null || typeof status === 'undefined') {
return null
}
if (status === 2) return 'En ligne'
if (status === 1) return 'Hors ligne'
if (status === 0) return 'Inactif'
return String(status)
}

View File

@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { MapPin } from 'lucide-react'
import { api } from '@/config/api'
import { ensureErrorMessage } from '@/helpers/ensureErrorMessage'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
export const LoginPage = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await api.post('/auth/login', { username, password })
window.location.href = '/admin'
} catch (err) {
const msg = ensureErrorMessage(err)
if (msg.includes('invalid_credentials')) {
setError('Nom d\'utilisateur ou mot de passe incorrect.')
} else {
setError(msg)
}
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-muted/30">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<MapPin className="h-6 w-6 text-primary" />
<span className="text-xl font-bold">Localiztoi</span>
</div>
<CardTitle className="text-lg">Connexion</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={(e) => void handleSubmit(e)} className="grid gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2">
<Label htmlFor="username">Nom d&apos;utilisateur</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoCapitalize="none"
autoComplete="username"
autoCorrect="off"
className="h-12"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="h-12"
required
/>
</div>
<Button type="submit" className="h-12 w-full" disabled={loading}>
{loading ? 'Connexion...' : 'Se connecter'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

Some files were not shown because too many files have changed in this diff Show More