Initial commit
This commit is contained in:
commit
5db26aa10f
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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*
|
||||
23
apps/api/README.md
Normal file
23
apps/api/README.md
Normal 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
23
apps/api/dist/db/index.js
vendored
Normal 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);
|
||||
};
|
||||
10
apps/api/dist/db/schema/amazonPollLog.js
vendored
Normal file
10
apps/api/dist/db/schema/amazonPollLog.js
vendored
Normal 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
15
apps/api/dist/db/schema/apiKeys.js
vendored
Normal 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
7
apps/api/dist/db/schema/index.js
vendored
Normal 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
27
apps/api/dist/db/schema/orderImeis.js
vendored
Normal 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
15
apps/api/dist/db/schema/orderItems.js
vendored
Normal 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
34
apps/api/dist/db/schema/orders.js
vendored
Normal 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
16
apps/api/dist/db/schema/skuMappings.js
vendored
Normal 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
9
apps/api/dist/db/schema/users.js
vendored
Normal 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
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
28
apps/api/dist/seed-admin.js
vendored
Normal 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);
|
||||
});
|
||||
10
apps/api/drizzle.config.ts
Normal file
10
apps/api/drizzle.config.ts
Normal 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 ?? ''
|
||||
}
|
||||
})
|
||||
11
apps/api/drizzle/0000_parallel_darkstar.sql
Normal file
11
apps/api/drizzle/0000_parallel_darkstar.sql
Normal 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");
|
||||
18
apps/api/drizzle/0001_huge_senator_kelly.sql
Normal file
18
apps/api/drizzle/0001_huge_senator_kelly.sql
Normal 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;
|
||||
9
apps/api/drizzle/0002_reflective_deathbird.sql
Normal file
9
apps/api/drizzle/0002_reflective_deathbird.sql
Normal 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");
|
||||
10
apps/api/drizzle/0003_lonely_turbo.sql
Normal file
10
apps/api/drizzle/0003_lonely_turbo.sql
Normal 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");
|
||||
13
apps/api/drizzle/0004_naive_rattler.sql
Normal file
13
apps/api/drizzle/0004_naive_rattler.sql
Normal 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;
|
||||
2
apps/api/drizzle/0005_nostalgic_shape.sql
Normal file
2
apps/api/drizzle/0005_nostalgic_shape.sql
Normal 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;
|
||||
4
apps/api/drizzle/0006_pink_gorgon.sql
Normal file
4
apps/api/drizzle/0006_pink_gorgon.sql
Normal 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;
|
||||
3
apps/api/drizzle/0007_lucky_karma.sql
Normal file
3
apps/api/drizzle/0007_lucky_karma.sql
Normal 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;
|
||||
9
apps/api/drizzle/0008_striped_morg.sql
Normal file
9
apps/api/drizzle/0008_striped_morg.sql
Normal 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
|
||||
);
|
||||
9
apps/api/drizzle/0009_sweet_wrecker.sql
Normal file
9
apps/api/drizzle/0009_sweet_wrecker.sql
Normal 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")
|
||||
);
|
||||
14
apps/api/drizzle/0010_short_king_bedlam.sql
Normal file
14
apps/api/drizzle/0010_short_king_bedlam.sql
Normal 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;
|
||||
93
apps/api/drizzle/meta/0000_snapshot.json
Normal file
93
apps/api/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
205
apps/api/drizzle/meta/0001_snapshot.json
Normal file
205
apps/api/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
280
apps/api/drizzle/meta/0002_snapshot.json
Normal file
280
apps/api/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
354
apps/api/drizzle/meta/0003_snapshot.json
Normal file
354
apps/api/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
432
apps/api/drizzle/meta/0004_snapshot.json
Normal file
432
apps/api/drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
444
apps/api/drizzle/meta/0005_snapshot.json
Normal file
444
apps/api/drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
468
apps/api/drizzle/meta/0006_snapshot.json
Normal file
468
apps/api/drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
486
apps/api/drizzle/meta/0007_snapshot.json
Normal file
486
apps/api/drizzle/meta/0007_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
546
apps/api/drizzle/meta/0008_snapshot.json
Normal file
546
apps/api/drizzle/meta/0008_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
606
apps/api/drizzle/meta/0009_snapshot.json
Normal file
606
apps/api/drizzle/meta/0009_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
690
apps/api/drizzle/meta/0010_snapshot.json
Normal file
690
apps/api/drizzle/meta/0010_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
83
apps/api/drizzle/meta/_journal.json
Normal file
83
apps/api/drizzle/meta/_journal.json
Normal 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
41
apps/api/package.json
Normal 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
30
apps/api/src/db/index.ts
Normal 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)
|
||||
}
|
||||
11
apps/api/src/db/schema/amazonPollLog.ts
Normal file
11
apps/api/src/db/schema/amazonPollLog.ts
Normal 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(),
|
||||
})
|
||||
20
apps/api/src/db/schema/apiKeys.ts
Normal file
20
apps/api/src/db/schema/apiKeys.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
7
apps/api/src/db/schema/index.ts
Normal file
7
apps/api/src/db/schema/index.ts
Normal 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'
|
||||
33
apps/api/src/db/schema/orderImeis.ts
Normal file
33
apps/api/src/db/schema/orderImeis.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
17
apps/api/src/db/schema/orderItems.ts
Normal file
17
apps/api/src/db/schema/orderItems.ts
Normal 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()
|
||||
})
|
||||
35
apps/api/src/db/schema/orders.ts
Normal file
35
apps/api/src/db/schema/orders.ts
Normal 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()
|
||||
})
|
||||
21
apps/api/src/db/schema/skuMappings.ts
Normal file
21
apps/api/src/db/schema/skuMappings.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
10
apps/api/src/db/schema/users.ts
Normal file
10
apps/api/src/db/schema/users.ts
Normal 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
2736
apps/api/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/api/src/seed-admin.ts
Normal file
36
apps/api/src/seed-admin.ts
Normal 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
16
apps/api/tsconfig.json
Normal 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
41
apps/web/.gitignore
vendored
Normal 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
36
apps/web/README.md
Normal 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
23
apps/web/components.json
Normal 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": {}
|
||||
}
|
||||
18
apps/web/eslint.config.mjs
Normal file
18
apps/web/eslint.config.mjs
Normal 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
22
apps/web/next.config.ts
Normal 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
40
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal 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 |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal 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
1
apps/web/public/next.svg
Normal 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 |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal 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 |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal 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 |
5
apps/web/src/app/admin/amazon-orders/page.tsx
Normal file
5
apps/web/src/app/admin/amazon-orders/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AmazonOrdersPage } from '@/components/admin/AmazonOrdersPage'
|
||||
|
||||
export default function Page() {
|
||||
return <AmazonOrdersPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/api-keys/page.tsx
Normal file
5
apps/web/src/app/admin/api-keys/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ApiKeysPage } from '@/components/admin/ApiKeysPage'
|
||||
|
||||
export default function Page() {
|
||||
return <ApiKeysPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/axonaut-stock/page.tsx
Normal file
5
apps/web/src/app/admin/axonaut-stock/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AxonautStockPage } from '@/components/admin/AxonautStockPage'
|
||||
|
||||
export default function Page() {
|
||||
return <AxonautStockPage />
|
||||
}
|
||||
32
apps/web/src/app/admin/layout.tsx
Normal file
32
apps/web/src/app/admin/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
apps/web/src/app/admin/orders/[id]/page.tsx
Normal file
10
apps/web/src/app/admin/orders/[id]/page.tsx
Normal 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} />
|
||||
}
|
||||
10
apps/web/src/app/admin/orders/[id]/scan-tracking/page.tsx
Normal file
10
apps/web/src/app/admin/orders/[id]/scan-tracking/page.tsx
Normal 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} />
|
||||
}
|
||||
10
apps/web/src/app/admin/orders/[id]/scan/page.tsx
Normal file
10
apps/web/src/app/admin/orders/[id]/scan/page.tsx
Normal 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} />
|
||||
}
|
||||
5
apps/web/src/app/admin/orders/new/page.tsx
Normal file
5
apps/web/src/app/admin/orders/new/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { NewOrderPage } from '@/components/admin/orders/NewOrderPage'
|
||||
|
||||
export default function Page() {
|
||||
return <NewOrderPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/orders/page.tsx
Normal file
5
apps/web/src/app/admin/orders/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { OrdersListPage } from '@/components/admin/orders/OrdersListPage'
|
||||
|
||||
export default function Page() {
|
||||
return <OrdersListPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/page.tsx
Normal file
5
apps/web/src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage'
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminDashboardPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/sku-mappings/page.tsx
Normal file
5
apps/web/src/app/admin/sku-mappings/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { SkuMappingsPage } from '@/components/admin/SkuMappingsPage'
|
||||
|
||||
export default function Page() {
|
||||
return <SkuMappingsPage />
|
||||
}
|
||||
5
apps/web/src/app/admin/users/page.tsx
Normal file
5
apps/web/src/app/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { UsersPage } from '@/components/admin/UsersPage'
|
||||
|
||||
export default function Page() {
|
||||
return <UsersPage />
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
130
apps/web/src/app/globals.css
Normal file
130
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
47
apps/web/src/app/layout.tsx
Normal file
47
apps/web/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/login/page.tsx
Normal file
5
apps/web/src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { LoginPage } from '@/components/auth/LoginPage'
|
||||
|
||||
export default function Page() {
|
||||
return <LoginPage />
|
||||
}
|
||||
141
apps/web/src/app/page.module.css
Normal file
141
apps/web/src/app/page.module.css
Normal 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
20
apps/web/src/app/page.tsx
Normal 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'administration</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
apps/web/src/components/admin/AdminDashboardPage.tsx
Normal file
249
apps/web/src/components/admin/AdminDashboardPage.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
162
apps/web/src/components/admin/AdminShell.tsx
Normal file
162
apps/web/src/components/admin/AdminShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
343
apps/web/src/components/admin/AmazonOrdersPage.tsx
Normal file
343
apps/web/src/components/admin/AmazonOrdersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
379
apps/web/src/components/admin/ApiKeysPage.tsx
Normal file
379
apps/web/src/components/admin/ApiKeysPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
apps/web/src/components/admin/AxonautStockPage.tsx
Normal file
202
apps/web/src/components/admin/AxonautStockPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
324
apps/web/src/components/admin/SkuMappingsPage.tsx
Normal file
324
apps/web/src/components/admin/SkuMappingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
333
apps/web/src/components/admin/UsersPage.tsx
Normal file
333
apps/web/src/components/admin/UsersPage.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
113
apps/web/src/components/admin/orders/ImeiValidationModal.tsx
Normal file
113
apps/web/src/components/admin/orders/ImeiValidationModal.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
198
apps/web/src/components/admin/orders/NewOrderPage.tsx
Normal file
198
apps/web/src/components/admin/orders/NewOrderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
920
apps/web/src/components/admin/orders/OrderDetailsPage.tsx
Normal file
920
apps/web/src/components/admin/orders/OrderDetailsPage.tsx
Normal 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'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'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 été mis à jour dans Axonaut et une note avec les IMEI a été 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 été transmis à Amazon. Le client a été notifié automatiquement.
|
||||
</p>
|
||||
) : !data.colissimoTrackingNumber ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Un numéro de suivi doit être associé avant de confirmer l'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'expédition sur Amazon</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer l'expédition sur Amazon ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Le numéro de suivi Colissimo sera transmis à Amazon. Le client recevra une notification d'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 été 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>
|
||||
)
|
||||
}
|
||||
168
apps/web/src/components/admin/orders/OrdersListPage.tsx
Normal file
168
apps/web/src/components/admin/orders/OrdersListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
795
apps/web/src/components/admin/orders/QrImeiScanner.tsx
Normal file
795
apps/web/src/components/admin/orders/QrImeiScanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
259
apps/web/src/components/admin/orders/ScanPage.tsx
Normal file
259
apps/web/src/components/admin/orders/ScanPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
apps/web/src/components/admin/orders/ScanTrackingPage.tsx
Normal file
245
apps/web/src/components/admin/orders/ScanTrackingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
apps/web/src/components/admin/orders/types.ts
Normal file
108
apps/web/src/components/admin/orders/types.ts
Normal 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)
|
||||
}
|
||||
94
apps/web/src/components/auth/LoginPage.tsx
Normal file
94
apps/web/src/components/auth/LoginPage.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
196
apps/web/src/components/ui/alert-dialog.tsx
Normal file
196
apps/web/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
66
apps/web/src/components/ui/alert.tsx
Normal file
66
apps/web/src/components/ui/alert.tsx
Normal 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 }
|
||||
48
apps/web/src/components/ui/badge.tsx
Normal file
48
apps/web/src/components/ui/badge.tsx
Normal 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
Loading…
Reference in New Issue
Block a user